2995 lines
87 KiB
Markdown
2995 lines
87 KiB
Markdown
# idfoto Core + CLI Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a working git-backed password manager with a Rust core library and CLI that can create vaults, add/get/list/edit/rm credentials, sync via git, and manage device keys — all backed by the reference-image + passphrase two-factor KDF.
|
||
|
||
**Architecture:** Cargo workspace with two crates: `idfoto-core` (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and `idfoto-cli` (filesystem, git, terminal I/O). The core takes bytes and returns bytes; the CLI handles all platform interaction. TDD throughout.
|
||
|
||
**Tech Stack:** Rust (stable, 2021 edition), argon2, chacha20poly1305, image, serde/serde_json, clap, ed25519-dalek
|
||
|
||
**Scope:** This is Plan 1 of 2. This plan covers `idfoto-core` and `idfoto-cli`. Plan 2 (idfoto-wasm + Chrome extension) follows after this is working. This plan produces a complete, usable CLI password manager.
|
||
|
||
**Prerequisites:** Rust stable installed via `rustup`. Git installed. A test JPEG image (any cell phone photo) available for manual testing.
|
||
|
||
**Design spec:** `docs/superpowers/specs/2026-04-11-idfoto-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
idfoto/ (project root = /home/alee/Sources/idfoto)
|
||
├── Cargo.toml # workspace root
|
||
├── crates/
|
||
│ ├── idfoto-core/
|
||
│ │ ├── Cargo.toml
|
||
│ │ └── src/
|
||
│ │ ├── lib.rs # re-exports public API
|
||
│ │ ├── error.rs # IdfotoError enum (thiserror)
|
||
│ │ ├── crypto.rs # derive_master_key(), encrypt(), decrypt()
|
||
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs
|
||
│ │ ├── vault.rs # encrypt/decrypt entries + manifest, binary format
|
||
│ │ └── imgsecret.rs # embed(), extract() — DCT embedding primitive
|
||
│ └── idfoto-cli/
|
||
│ ├── Cargo.toml
|
||
│ └── src/
|
||
│ └── main.rs # clap CLI with all subcommands
|
||
├── docs/
|
||
│ └── superpowers/
|
||
│ ├── specs/
|
||
│ │ └── 2026-04-11-idfoto-design.md
|
||
│ └── plans/
|
||
│ └── 2026-04-11-idfoto-core-cli.md (this file)
|
||
└── README.md
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Workspace Scaffolding
|
||
|
||
**Files:**
|
||
- Create: `Cargo.toml`
|
||
- Create: `crates/idfoto-core/Cargo.toml`
|
||
- Create: `crates/idfoto-core/src/lib.rs`
|
||
- Create: `crates/idfoto-cli/Cargo.toml`
|
||
- Create: `crates/idfoto-cli/src/main.rs`
|
||
|
||
- [ ] **Step 1: Create workspace root Cargo.toml**
|
||
|
||
```toml
|
||
# Cargo.toml
|
||
[workspace]
|
||
resolver = "2"
|
||
members = [
|
||
"crates/idfoto-core",
|
||
"crates/idfoto-cli",
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2: Create idfoto-core crate**
|
||
|
||
```toml
|
||
# crates/idfoto-core/Cargo.toml
|
||
[package]
|
||
name = "idfoto-core"
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
description = "Core library for idfoto password manager"
|
||
|
||
[dependencies]
|
||
thiserror = "2"
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
argon2 = "0.5"
|
||
chacha20poly1305 = "0.10"
|
||
rand = "0.8"
|
||
sha2 = "0.10"
|
||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||
|
||
[dev-dependencies]
|
||
```
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/lib.rs
|
||
pub mod error;
|
||
```
|
||
|
||
- [ ] **Step 3: Create idfoto-cli crate**
|
||
|
||
```toml
|
||
# crates/idfoto-cli/Cargo.toml
|
||
[package]
|
||
name = "idfoto-cli"
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
description = "CLI for idfoto password manager"
|
||
|
||
[[bin]]
|
||
name = "idfoto"
|
||
path = "src/main.rs"
|
||
|
||
[dependencies]
|
||
idfoto-core = { path = "../idfoto-core" }
|
||
clap = { version = "4", features = ["derive"] }
|
||
anyhow = "1"
|
||
rpassword = "5"
|
||
arboard = "3"
|
||
dirs = "5"
|
||
```
|
||
|
||
```rust
|
||
// crates/idfoto-cli/src/main.rs
|
||
fn main() {
|
||
println!("idfoto v0.1.0");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify build**
|
||
|
||
Run: `cargo build`
|
||
Expected: Compiles with no errors. May show warnings about unused dependencies — that's fine.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git init
|
||
echo "target/" > .gitignore
|
||
echo ".superpowers/" >> .gitignore
|
||
git add Cargo.toml crates/ .gitignore docs/
|
||
git commit -m "feat: scaffold Cargo workspace with idfoto-core and idfoto-cli"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Error Types
|
||
|
||
**Files:**
|
||
- Create: `crates/idfoto-core/src/error.rs`
|
||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Write the error enum**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/error.rs
|
||
use thiserror::Error;
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum IdfotoError {
|
||
#[error("key derivation failed: {0}")]
|
||
Kdf(String),
|
||
|
||
#[error("encryption failed: {0}")]
|
||
Encrypt(String),
|
||
|
||
#[error("decryption failed: wrong key or corrupted data")]
|
||
Decrypt,
|
||
|
||
#[error("invalid vault format: {0}")]
|
||
Format(String),
|
||
|
||
#[error("entry not found: {0}")]
|
||
EntryNotFound(String),
|
||
|
||
#[error("imgsecret: {0}")]
|
||
ImgSecret(String),
|
||
|
||
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
|
||
ImageTooSmall {
|
||
min_width: u32,
|
||
min_height: u32,
|
||
actual_width: u32,
|
||
actual_height: u32,
|
||
},
|
||
|
||
#[error("extraction failed: no valid secret found in image")]
|
||
ExtractionFailed,
|
||
|
||
#[error("json error: {0}")]
|
||
Json(#[from] serde_json::Error),
|
||
|
||
#[error("device key error: {0}")]
|
||
DeviceKey(String),
|
||
}
|
||
|
||
pub type Result<T> = std::result::Result<T, IdfotoError>;
|
||
```
|
||
|
||
- [ ] **Step 2: Update lib.rs to re-export**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/lib.rs
|
||
pub mod error;
|
||
|
||
pub use error::{IdfotoError, Result};
|
||
```
|
||
|
||
- [ ] **Step 3: Verify build**
|
||
|
||
Run: `cargo build`
|
||
Expected: Compiles cleanly.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/error.rs crates/idfoto-core/src/lib.rs
|
||
git commit -m "feat: add IdfotoError enum with thiserror"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Crypto — Key Derivation
|
||
|
||
**Files:**
|
||
- Create: `crates/idfoto-core/src/crypto.rs`
|
||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/crypto.rs
|
||
|
||
// ... (implementation comes in step 3)
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn derive_master_key_deterministic() {
|
||
let passphrase = b"apple forest thunder mountain";
|
||
let image_secret = [0xABu8; 32];
|
||
let salt = [0x01u8; 32];
|
||
let params = KdfParams::default();
|
||
|
||
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||
assert_eq!(key1, key2, "same inputs must produce same key");
|
||
}
|
||
|
||
#[test]
|
||
fn derive_master_key_different_passphrase() {
|
||
let image_secret = [0xABu8; 32];
|
||
let salt = [0x01u8; 32];
|
||
let params = KdfParams::default();
|
||
|
||
let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap();
|
||
let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap();
|
||
assert_ne!(key1, key2);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_master_key_different_image_secret() {
|
||
let passphrase = b"same passphrase";
|
||
let salt = [0x01u8; 32];
|
||
let params = KdfParams::default();
|
||
|
||
let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap();
|
||
let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap();
|
||
assert_ne!(key1, key2);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cargo test -p idfoto-core derive_master_key`
|
||
Expected: FAIL — `derive_master_key` and `KdfParams` not defined.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/crypto.rs
|
||
use argon2::{Algorithm, Argon2, Params, Version};
|
||
use crate::error::{IdfotoError, Result};
|
||
|
||
/// Argon2id tuning parameters. Stored in .idfoto/params.json.
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||
pub struct KdfParams {
|
||
/// Memory cost in KiB (default: 65536 = 64 MiB)
|
||
pub argon2_m: u32,
|
||
/// Time cost / iterations (default: 3)
|
||
pub argon2_t: u32,
|
||
/// Parallelism (default: 4)
|
||
pub argon2_p: u32,
|
||
}
|
||
|
||
impl Default for KdfParams {
|
||
fn default() -> Self {
|
||
Self {
|
||
argon2_m: 65536,
|
||
argon2_t: 3,
|
||
argon2_p: 4,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Derive a 32-byte master key from passphrase + image_secret + salt.
|
||
///
|
||
/// password = passphrase_bytes || image_secret_bytes (concatenated)
|
||
/// salt = vault_salt (32 bytes from .idfoto/salt)
|
||
pub fn derive_master_key(
|
||
passphrase: &[u8],
|
||
image_secret: &[u8; 32],
|
||
salt: &[u8; 32],
|
||
params: &KdfParams,
|
||
) -> Result<[u8; 32]> {
|
||
// Concatenate passphrase and image_secret as the Argon2id "password" input
|
||
let mut password = Vec::with_capacity(passphrase.len() + 32);
|
||
password.extend_from_slice(passphrase);
|
||
password.extend_from_slice(image_secret);
|
||
|
||
let argon2_params = Params::new(
|
||
params.argon2_m,
|
||
params.argon2_t,
|
||
params.argon2_p,
|
||
Some(32),
|
||
)
|
||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
||
|
||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||
|
||
let mut output = [0u8; 32];
|
||
argon2
|
||
.hash_password_into(&password, salt, &mut output)
|
||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
||
|
||
Ok(output)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// Use fast params for tests so they don't take forever
|
||
fn test_params() -> KdfParams {
|
||
KdfParams {
|
||
argon2_m: 256, // 256 KiB — fast for tests
|
||
argon2_t: 1,
|
||
argon2_p: 1,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn derive_master_key_deterministic() {
|
||
let passphrase = b"apple forest thunder mountain";
|
||
let image_secret = [0xABu8; 32];
|
||
let salt = [0x01u8; 32];
|
||
let params = test_params();
|
||
|
||
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||
assert_eq!(key1, key2, "same inputs must produce same key");
|
||
}
|
||
|
||
#[test]
|
||
fn derive_master_key_different_passphrase() {
|
||
let image_secret = [0xABu8; 32];
|
||
let salt = [0x01u8; 32];
|
||
let params = test_params();
|
||
|
||
let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap();
|
||
let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap();
|
||
assert_ne!(key1, key2);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_master_key_different_image_secret() {
|
||
let passphrase = b"same passphrase";
|
||
let salt = [0x01u8; 32];
|
||
let params = test_params();
|
||
|
||
let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap();
|
||
let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap();
|
||
assert_ne!(key1, key2);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
Run: `cargo test -p idfoto-core derive_master_key`
|
||
Expected: All 3 tests PASS.
|
||
|
||
- [ ] **Step 5: Update lib.rs**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/lib.rs
|
||
pub mod crypto;
|
||
pub mod error;
|
||
|
||
pub use crypto::{derive_master_key, KdfParams};
|
||
pub use error::{IdfotoError, Result};
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/
|
||
git commit -m "feat: add Argon2id key derivation with tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Crypto — Encrypt / Decrypt
|
||
|
||
**Files:**
|
||
- Modify: `crates/idfoto-core/src/crypto.rs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `crates/idfoto-core/src/crypto.rs` inside the `mod tests` block:
|
||
|
||
```rust
|
||
#[test]
|
||
fn encrypt_decrypt_round_trip() {
|
||
let key = [0x42u8; 32];
|
||
let plaintext = b"hello world, this is a secret";
|
||
|
||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||
let decrypted = decrypt(&key, &ciphertext).unwrap();
|
||
assert_eq!(decrypted, plaintext);
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_wrong_key_fails() {
|
||
let key = [0x42u8; 32];
|
||
let wrong_key = [0x43u8; 32];
|
||
let plaintext = b"secret data";
|
||
|
||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||
let result = decrypt(&wrong_key, &ciphertext);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn decrypt_tampered_data_fails() {
|
||
let key = [0x42u8; 32];
|
||
let plaintext = b"secret data";
|
||
|
||
let mut ciphertext = encrypt(&key, plaintext).unwrap();
|
||
// Flip a byte in the ciphertext portion (after version + nonce = 25 bytes)
|
||
if ciphertext.len() > 26 {
|
||
ciphertext[26] ^= 0xFF;
|
||
}
|
||
let result = decrypt(&key, &ciphertext);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn ciphertext_format_has_correct_structure() {
|
||
let key = [0x42u8; 32];
|
||
let plaintext = b"test";
|
||
|
||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||
// version(1) + nonce(24) + ciphertext(4) + tag(16) = 45
|
||
assert_eq!(ciphertext.len(), 1 + 24 + plaintext.len() + 16);
|
||
assert_eq!(ciphertext[0], 0x01); // version byte
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cargo test -p idfoto-core encrypt`
|
||
Expected: FAIL — `encrypt` and `decrypt` not defined.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Add to `crates/idfoto-core/src/crypto.rs`, above the `#[cfg(test)]` block:
|
||
|
||
```rust
|
||
use chacha20poly1305::{
|
||
aead::{Aead, KeyInit, OsRng},
|
||
XChaCha20Poly1305, XNonce,
|
||
};
|
||
use rand::RngCore;
|
||
|
||
/// Current format version byte.
|
||
const FORMAT_VERSION: u8 = 0x01;
|
||
/// XChaCha20-Poly1305 nonce size in bytes.
|
||
const NONCE_SIZE: usize = 24;
|
||
|
||
/// Encrypt plaintext with XChaCha20-Poly1305.
|
||
///
|
||
/// Output format: version(1) || nonce(24) || ciphertext(N) || tag(16)
|
||
/// Nonce is generated fresh from CSPRNG on each call.
|
||
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||
let cipher = XChaCha20Poly1305::new(key.into());
|
||
|
||
let mut nonce_bytes = [0u8; NONCE_SIZE];
|
||
OsRng.fill_bytes(&mut nonce_bytes);
|
||
let nonce = XNonce::from_slice(&nonce_bytes);
|
||
|
||
let ciphertext = cipher
|
||
.encrypt(nonce, plaintext)
|
||
.map_err(|_| IdfotoError::Encrypt("XChaCha20-Poly1305 encryption failed".into()))?;
|
||
|
||
let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
|
||
output.push(FORMAT_VERSION);
|
||
output.extend_from_slice(&nonce_bytes);
|
||
output.extend_from_slice(&ciphertext);
|
||
Ok(output)
|
||
}
|
||
|
||
/// Decrypt ciphertext produced by encrypt().
|
||
///
|
||
/// Expects format: version(1) || nonce(24) || ciphertext(N) || tag(16)
|
||
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||
let min_len = 1 + NONCE_SIZE + 16; // version + nonce + tag (empty plaintext)
|
||
if data.len() < min_len {
|
||
return Err(IdfotoError::Format(format!(
|
||
"ciphertext too short: {} bytes, need at least {}",
|
||
data.len(),
|
||
min_len
|
||
)));
|
||
}
|
||
|
||
let version = data[0];
|
||
if version != FORMAT_VERSION {
|
||
return Err(IdfotoError::Format(format!(
|
||
"unsupported format version: {version}"
|
||
)));
|
||
}
|
||
|
||
let nonce = XNonce::from_slice(&data[1..1 + NONCE_SIZE]);
|
||
let ciphertext = &data[1 + NONCE_SIZE..];
|
||
|
||
let cipher = XChaCha20Poly1305::new(key.into());
|
||
cipher
|
||
.decrypt(nonce, ciphertext)
|
||
.map_err(|_| IdfotoError::Decrypt)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Fix the import for OsRng**
|
||
|
||
The `OsRng` import from chacha20poly1305 may not be re-exported. Update the imports at the top of crypto.rs:
|
||
|
||
```rust
|
||
use chacha20poly1305::{
|
||
aead::{Aead, KeyInit},
|
||
XChaCha20Poly1305, XNonce,
|
||
};
|
||
use rand::rngs::OsRng;
|
||
use rand::RngCore;
|
||
```
|
||
|
||
- [ ] **Step 5: Run all crypto tests**
|
||
|
||
Run: `cargo test -p idfoto-core`
|
||
Expected: All tests PASS (3 KDF tests + 4 encrypt/decrypt tests).
|
||
|
||
- [ ] **Step 6: Update lib.rs exports**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/lib.rs
|
||
pub mod crypto;
|
||
pub mod error;
|
||
|
||
pub use crypto::{derive_master_key, encrypt, decrypt, KdfParams};
|
||
pub use error::{IdfotoError, Result};
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/
|
||
git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Entry & Manifest Data Model
|
||
|
||
**Files:**
|
||
- Create: `crates/idfoto-core/src/entry.rs`
|
||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Write tests for serialization**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/entry.rs
|
||
|
||
// ... (implementation in step 3)
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn entry_serialization_round_trip() {
|
||
let entry = Entry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com/login".into()),
|
||
username: Some("alee".into()),
|
||
password: "hunter2".into(),
|
||
notes: Some("2FA enabled".into()),
|
||
totp_secret: None,
|
||
created_at: "2026-04-11T22:30:00Z".into(),
|
||
updated_at: "2026-04-11T22:30:00Z".into(),
|
||
};
|
||
|
||
let json = serde_json::to_string(&entry).unwrap();
|
||
let deserialized: Entry = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deserialized.name, "GitHub");
|
||
assert_eq!(deserialized.password, "hunter2");
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_add_and_lookup() {
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"a1b2c3d4".into(),
|
||
ManifestEntry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
updated_at: "2026-04-11T22:30:00Z".into(),
|
||
},
|
||
);
|
||
|
||
assert_eq!(manifest.entries.len(), 1);
|
||
assert!(manifest.entries.contains_key("a1b2c3d4"));
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_serialization_round_trip() {
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"a1b2c3d4".into(),
|
||
ManifestEntry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
updated_at: "2026-04-11T22:30:00Z".into(),
|
||
},
|
||
);
|
||
|
||
let json = serde_json::to_string(&manifest).unwrap();
|
||
let deserialized: Manifest = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deserialized.entries.len(), 1);
|
||
assert_eq!(deserialized.version, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn generate_entry_id_is_8_hex_chars() {
|
||
let id = generate_entry_id();
|
||
assert_eq!(id.len(), 8);
|
||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cargo test -p idfoto-core entry`
|
||
Expected: FAIL — types not defined.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/entry.rs
|
||
use rand::Rng;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
|
||
/// A single password entry (stored encrypted in entries/<id>.enc).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Entry {
|
||
pub name: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub url: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub username: Option<String>,
|
||
pub password: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub notes: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub totp_secret: Option<String>,
|
||
pub created_at: String,
|
||
pub updated_at: String,
|
||
}
|
||
|
||
/// Summary info about an entry (stored in the manifest).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ManifestEntry {
|
||
pub name: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub url: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub username: Option<String>,
|
||
pub updated_at: String,
|
||
}
|
||
|
||
/// The vault manifest — maps entry IDs to their metadata.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Manifest {
|
||
pub entries: HashMap<String, ManifestEntry>,
|
||
pub version: u32,
|
||
}
|
||
|
||
impl Manifest {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
entries: HashMap::new(),
|
||
version: 1,
|
||
}
|
||
}
|
||
|
||
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
|
||
self.entries.insert(id, entry);
|
||
}
|
||
|
||
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
|
||
self.entries.remove(id)
|
||
}
|
||
|
||
/// Case-insensitive substring search on name and URL.
|
||
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
|
||
let query_lower = query.to_lowercase();
|
||
self.entries
|
||
.iter()
|
||
.filter(|(_, e)| {
|
||
e.name.to_lowercase().contains(&query_lower)
|
||
|| e.url
|
||
.as_ref()
|
||
.map(|u| u.to_lowercase().contains(&query_lower))
|
||
.unwrap_or(false)
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// Generate a random 8-character hex entry ID.
|
||
pub fn generate_entry_id() -> String {
|
||
let mut rng = rand::thread_rng();
|
||
let value: u32 = rng.gen();
|
||
format!("{:08x}", value)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn entry_serialization_round_trip() {
|
||
let entry = Entry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com/login".into()),
|
||
username: Some("alee".into()),
|
||
password: "hunter2".into(),
|
||
notes: Some("2FA enabled".into()),
|
||
totp_secret: None,
|
||
created_at: "2026-04-11T22:30:00Z".into(),
|
||
updated_at: "2026-04-11T22:30:00Z".into(),
|
||
};
|
||
|
||
let json = serde_json::to_string(&entry).unwrap();
|
||
let deserialized: Entry = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deserialized.name, "GitHub");
|
||
assert_eq!(deserialized.password, "hunter2");
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_add_and_lookup() {
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"a1b2c3d4".into(),
|
||
ManifestEntry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
updated_at: "2026-04-11T22:30:00Z".into(),
|
||
},
|
||
);
|
||
|
||
assert_eq!(manifest.entries.len(), 1);
|
||
assert!(manifest.entries.contains_key("a1b2c3d4"));
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_serialization_round_trip() {
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"a1b2c3d4".into(),
|
||
ManifestEntry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
updated_at: "2026-04-11T22:30:00Z".into(),
|
||
},
|
||
);
|
||
|
||
let json = serde_json::to_string(&manifest).unwrap();
|
||
let deserialized: Manifest = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(deserialized.entries.len(), 1);
|
||
assert_eq!(deserialized.version, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn generate_entry_id_is_8_hex_chars() {
|
||
let id = generate_entry_id();
|
||
assert_eq!(id.len(), 8);
|
||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_search_case_insensitive() {
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"aaa".into(),
|
||
ManifestEntry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: None,
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
},
|
||
);
|
||
manifest.add_entry(
|
||
"bbb".into(),
|
||
ManifestEntry {
|
||
name: "Netflix".into(),
|
||
url: Some("https://netflix.com".into()),
|
||
username: None,
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
},
|
||
);
|
||
|
||
let results = manifest.search("github");
|
||
assert_eq!(results.len(), 1);
|
||
assert_eq!(results[0].1.name, "GitHub");
|
||
|
||
let results = manifest.search("flix");
|
||
assert_eq!(results.len(), 1);
|
||
assert_eq!(results[0].1.name, "Netflix");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update lib.rs**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/lib.rs
|
||
pub mod crypto;
|
||
pub mod entry;
|
||
pub mod error;
|
||
|
||
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
|
||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||
pub use error::{IdfotoError, Result};
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
Run: `cargo test -p idfoto-core entry`
|
||
Expected: All 5 entry tests PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/
|
||
git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Vault Operations
|
||
|
||
**Files:**
|
||
- Create: `crates/idfoto-core/src/vault.rs`
|
||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/vault.rs
|
||
|
||
// ... (implementation in step 3)
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::entry::{Entry, Manifest, ManifestEntry};
|
||
|
||
#[test]
|
||
fn entry_encrypt_decrypt_round_trip() {
|
||
let key = [0x42u8; 32];
|
||
let entry = Entry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
password: "secret123".into(),
|
||
notes: None,
|
||
totp_secret: None,
|
||
created_at: "2026-04-11T00:00:00Z".into(),
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
};
|
||
|
||
let encrypted = encrypt_entry(&key, &entry).unwrap();
|
||
let decrypted = decrypt_entry(&key, &encrypted).unwrap();
|
||
assert_eq!(decrypted.name, "GitHub");
|
||
assert_eq!(decrypted.password, "secret123");
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_encrypt_decrypt_round_trip() {
|
||
let key = [0x42u8; 32];
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"abc123".into(),
|
||
ManifestEntry {
|
||
name: "Test".into(),
|
||
url: None,
|
||
username: None,
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
},
|
||
);
|
||
|
||
let encrypted = encrypt_manifest(&key, &manifest).unwrap();
|
||
let decrypted = decrypt_manifest(&key, &encrypted).unwrap();
|
||
assert_eq!(decrypted.entries.len(), 1);
|
||
assert!(decrypted.entries.contains_key("abc123"));
|
||
}
|
||
|
||
#[test]
|
||
fn entry_wrong_key_fails() {
|
||
let key = [0x42u8; 32];
|
||
let wrong_key = [0x43u8; 32];
|
||
let entry = Entry {
|
||
name: "Test".into(),
|
||
url: None,
|
||
username: None,
|
||
password: "pass".into(),
|
||
notes: None,
|
||
totp_secret: None,
|
||
created_at: "2026-04-11T00:00:00Z".into(),
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
};
|
||
|
||
let encrypted = encrypt_entry(&key, &entry).unwrap();
|
||
assert!(decrypt_entry(&wrong_key, &encrypted).is_err());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cargo test -p idfoto-core vault`
|
||
Expected: FAIL — functions not defined.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/vault.rs
|
||
use crate::crypto;
|
||
use crate::entry::{Entry, Manifest};
|
||
use crate::error::Result;
|
||
|
||
/// Encrypt an Entry to bytes (JSON serialized, then encrypted).
|
||
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
|
||
let json = serde_json::to_vec(entry)?;
|
||
crypto::encrypt(master_key, &json)
|
||
}
|
||
|
||
/// Decrypt bytes back to an Entry.
|
||
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
|
||
let json = crypto::decrypt(master_key, data)?;
|
||
let entry: Entry = serde_json::from_slice(&json)?;
|
||
Ok(entry)
|
||
}
|
||
|
||
/// Encrypt the Manifest to bytes.
|
||
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
|
||
let json = serde_json::to_vec(manifest)?;
|
||
crypto::encrypt(master_key, &json)
|
||
}
|
||
|
||
/// Decrypt bytes back to a Manifest.
|
||
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
|
||
let json = crypto::decrypt(master_key, data)?;
|
||
let manifest: Manifest = serde_json::from_slice(&json)?;
|
||
Ok(manifest)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::entry::{Entry, Manifest, ManifestEntry};
|
||
|
||
#[test]
|
||
fn entry_encrypt_decrypt_round_trip() {
|
||
let key = [0x42u8; 32];
|
||
let entry = Entry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
password: "secret123".into(),
|
||
notes: None,
|
||
totp_secret: None,
|
||
created_at: "2026-04-11T00:00:00Z".into(),
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
};
|
||
|
||
let encrypted = encrypt_entry(&key, &entry).unwrap();
|
||
let decrypted = decrypt_entry(&key, &encrypted).unwrap();
|
||
assert_eq!(decrypted.name, "GitHub");
|
||
assert_eq!(decrypted.password, "secret123");
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_encrypt_decrypt_round_trip() {
|
||
let key = [0x42u8; 32];
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"abc123".into(),
|
||
ManifestEntry {
|
||
name: "Test".into(),
|
||
url: None,
|
||
username: None,
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
},
|
||
);
|
||
|
||
let encrypted = encrypt_manifest(&key, &manifest).unwrap();
|
||
let decrypted = decrypt_manifest(&key, &encrypted).unwrap();
|
||
assert_eq!(decrypted.entries.len(), 1);
|
||
assert!(decrypted.entries.contains_key("abc123"));
|
||
}
|
||
|
||
#[test]
|
||
fn entry_wrong_key_fails() {
|
||
let key = [0x42u8; 32];
|
||
let wrong_key = [0x43u8; 32];
|
||
let entry = Entry {
|
||
name: "Test".into(),
|
||
url: None,
|
||
username: None,
|
||
password: "pass".into(),
|
||
notes: None,
|
||
totp_secret: None,
|
||
created_at: "2026-04-11T00:00:00Z".into(),
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
};
|
||
|
||
let encrypted = encrypt_entry(&key, &entry).unwrap();
|
||
assert!(decrypt_entry(&wrong_key, &encrypted).is_err());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update lib.rs**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/lib.rs
|
||
pub mod crypto;
|
||
pub mod entry;
|
||
pub mod error;
|
||
pub mod vault;
|
||
|
||
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
|
||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||
pub use error::{IdfotoError, Result};
|
||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
||
```
|
||
|
||
- [ ] **Step 5: Run all tests**
|
||
|
||
Run: `cargo test -p idfoto-core`
|
||
Expected: All tests PASS (KDF + encrypt/decrypt + entry + vault).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/
|
||
git commit -m "feat: add vault encrypt/decrypt for entries and manifest"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: imgsecret — JPEG Decode, Y Channel, Block DCT
|
||
|
||
**Files:**
|
||
- Create: `crates/idfoto-core/src/imgsecret.rs`
|
||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||
|
||
This task builds the image-processing foundation. No embedding yet — just: load JPEG → extract luminance → divide into 8×8 blocks → DCT forward/inverse.
|
||
|
||
- [ ] **Step 1: Write tests for DCT round-trip and Y channel extraction**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/imgsecret.rs
|
||
|
||
// ... (implementation in step 3)
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn dct2_idct2_round_trip() {
|
||
let block: [f64; 64] = {
|
||
let mut b = [0.0; 64];
|
||
for i in 0..64 {
|
||
b[i] = (i as f64) * 3.7 - 100.0;
|
||
}
|
||
b
|
||
};
|
||
|
||
let dct = dct2_8x8(&block);
|
||
let recovered = idct2_8x8(&dct);
|
||
|
||
for i in 0..64 {
|
||
assert!(
|
||
(block[i] - recovered[i]).abs() < 1e-6,
|
||
"mismatch at index {i}: {} vs {}",
|
||
block[i],
|
||
recovered[i]
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn extract_y_channel_from_synthetic_jpeg() {
|
||
let jpeg_bytes = make_test_jpeg(64, 64);
|
||
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
|
||
assert_eq!(y_channel.width, 64);
|
||
assert_eq!(y_channel.height, 64);
|
||
assert_eq!(y_channel.data.len(), 64 * 64);
|
||
}
|
||
|
||
#[test]
|
||
fn get_blocks_from_region() {
|
||
let jpeg_bytes = make_test_jpeg(80, 80);
|
||
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
|
||
// Central 70% of 80px = 56px. With 15% margin each side = 12px offset.
|
||
// 56/8 = 7 blocks per dimension, 49 blocks total in central region.
|
||
let region = central_region(&y_channel);
|
||
assert!(region.blocks_x > 0);
|
||
assert!(region.blocks_y > 0);
|
||
}
|
||
|
||
/// Create a synthetic JPEG for testing.
|
||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
||
use image::{ImageBuffer, Rgb, ImageEncoder};
|
||
use image::codecs::jpeg::JpegEncoder;
|
||
|
||
let img = ImageBuffer::from_fn(width, height, |x, y| {
|
||
Rgb([
|
||
((x * 7 + y * 13) % 256) as u8,
|
||
((x * 11 + y * 3) % 256) as u8,
|
||
((x * 5 + y * 17) % 256) as u8,
|
||
])
|
||
});
|
||
|
||
let mut buf = Vec::new();
|
||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||
encoder
|
||
.write_image(
|
||
img.as_raw(),
|
||
width,
|
||
height,
|
||
image::ExtendedColorType::Rgb8,
|
||
)
|
||
.unwrap();
|
||
buf
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cargo test -p idfoto-core imgsecret`
|
||
Expected: FAIL — functions not defined.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/imgsecret.rs
|
||
use crate::error::{IdfotoError, Result};
|
||
use image::io::Reader as ImageReader;
|
||
use std::f64::consts::PI;
|
||
use std::io::Cursor;
|
||
|
||
const BLOCK_SIZE: usize = 8;
|
||
|
||
/// Y (luminance) channel data extracted from a JPEG.
|
||
pub struct YChannel {
|
||
pub data: Vec<f64>,
|
||
pub width: usize,
|
||
pub height: usize,
|
||
}
|
||
|
||
/// Describes the central embedding region and its block grid.
|
||
pub struct EmbedRegion {
|
||
pub x_offset: usize,
|
||
pub y_offset: usize,
|
||
pub region_width: usize,
|
||
pub region_height: usize,
|
||
pub blocks_x: usize,
|
||
pub blocks_y: usize,
|
||
}
|
||
|
||
/// Extract the Y (luminance) channel from JPEG bytes.
|
||
pub fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
||
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
||
.with_guessed_format()
|
||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
||
|
||
let img = reader
|
||
.decode()
|
||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||
|
||
let rgb = img.to_rgb8();
|
||
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
|
||
|
||
// ITU-R BT.601 luminance conversion
|
||
let mut y_data = Vec::with_capacity(width * height);
|
||
for pixel in rgb.pixels() {
|
||
let r = pixel[0] as f64;
|
||
let g = pixel[1] as f64;
|
||
let b = pixel[2] as f64;
|
||
y_data.push(0.299 * r + 0.587 * g + 0.114 * b);
|
||
}
|
||
|
||
Ok(YChannel {
|
||
data: y_data,
|
||
width,
|
||
height,
|
||
})
|
||
}
|
||
|
||
/// Calculate the central 70% embedding region (15% margin on each side).
|
||
pub fn central_region(y: &YChannel) -> EmbedRegion {
|
||
let margin_x = y.width * 15 / 100;
|
||
let margin_y = y.height * 15 / 100;
|
||
let x_offset = margin_x;
|
||
let y_offset = margin_y;
|
||
let region_width = y.width - 2 * margin_x;
|
||
let region_height = y.height - 2 * margin_y;
|
||
let blocks_x = region_width / BLOCK_SIZE;
|
||
let blocks_y = region_height / BLOCK_SIZE;
|
||
|
||
EmbedRegion {
|
||
x_offset,
|
||
y_offset,
|
||
region_width,
|
||
region_height,
|
||
blocks_x,
|
||
blocks_y,
|
||
}
|
||
}
|
||
|
||
/// Read an 8×8 block from the Y channel at block coordinates (bx, by)
|
||
/// within the given embedding region.
|
||
pub fn read_block(y: &YChannel, region: &EmbedRegion, bx: usize, by: usize) -> [f64; 64] {
|
||
let mut block = [0.0f64; 64];
|
||
let px_x = region.x_offset + bx * BLOCK_SIZE;
|
||
let px_y = region.y_offset + by * BLOCK_SIZE;
|
||
|
||
for row in 0..BLOCK_SIZE {
|
||
for col in 0..BLOCK_SIZE {
|
||
let idx = (px_y + row) * y.width + (px_x + col);
|
||
block[row * BLOCK_SIZE + col] = y.data[idx];
|
||
}
|
||
}
|
||
block
|
||
}
|
||
|
||
/// Write an 8×8 block back to the Y channel.
|
||
pub fn write_block(y: &mut YChannel, region: &EmbedRegion, bx: usize, by: usize, block: &[f64; 64]) {
|
||
let px_x = region.x_offset + bx * BLOCK_SIZE;
|
||
let px_y = region.y_offset + by * BLOCK_SIZE;
|
||
|
||
for row in 0..BLOCK_SIZE {
|
||
for col in 0..BLOCK_SIZE {
|
||
let idx = (px_y + row) * y.width + (px_x + col);
|
||
y.data[idx] = block[row * BLOCK_SIZE + col].clamp(0.0, 255.0);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 2D Type-II DCT on an 8×8 block (separable: rows then columns).
|
||
pub fn dct2_8x8(block: &[f64; 64]) -> [f64; 64] {
|
||
let mut temp = [0.0f64; 64];
|
||
let mut result = [0.0f64; 64];
|
||
|
||
// DCT on each row
|
||
for row in 0..BLOCK_SIZE {
|
||
let src_offset = row * BLOCK_SIZE;
|
||
let mut row_data = [0.0f64; BLOCK_SIZE];
|
||
row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]);
|
||
let dct_row = dct1d_8(&row_data);
|
||
temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&dct_row);
|
||
}
|
||
|
||
// DCT on each column of the result
|
||
for col in 0..BLOCK_SIZE {
|
||
let mut col_data = [0.0f64; BLOCK_SIZE];
|
||
for row in 0..BLOCK_SIZE {
|
||
col_data[row] = temp[row * BLOCK_SIZE + col];
|
||
}
|
||
let dct_col = dct1d_8(&col_data);
|
||
for row in 0..BLOCK_SIZE {
|
||
result[row * BLOCK_SIZE + col] = dct_col[row];
|
||
}
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// 2D inverse DCT on an 8×8 block.
|
||
pub fn idct2_8x8(block: &[f64; 64]) -> [f64; 64] {
|
||
let mut temp = [0.0f64; 64];
|
||
let mut result = [0.0f64; 64];
|
||
|
||
// IDCT on each row
|
||
for row in 0..BLOCK_SIZE {
|
||
let src_offset = row * BLOCK_SIZE;
|
||
let mut row_data = [0.0f64; BLOCK_SIZE];
|
||
row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]);
|
||
let idct_row = idct1d_8(&row_data);
|
||
temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&idct_row);
|
||
}
|
||
|
||
// IDCT on each column
|
||
for col in 0..BLOCK_SIZE {
|
||
let mut col_data = [0.0f64; BLOCK_SIZE];
|
||
for row in 0..BLOCK_SIZE {
|
||
col_data[row] = temp[row * BLOCK_SIZE + col];
|
||
}
|
||
let idct_col = idct1d_8(&col_data);
|
||
for row in 0..BLOCK_SIZE {
|
||
result[row * BLOCK_SIZE + col] = idct_col[row];
|
||
}
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// 1D Type-II DCT for N=8 (orthonormal).
|
||
fn dct1d_8(input: &[f64; 8]) -> [f64; 8] {
|
||
let mut output = [0.0f64; 8];
|
||
let n = BLOCK_SIZE as f64;
|
||
|
||
for k in 0..BLOCK_SIZE {
|
||
let mut sum = 0.0;
|
||
for i in 0..BLOCK_SIZE {
|
||
sum += input[i] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos();
|
||
}
|
||
let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() };
|
||
output[k] = ck * sum;
|
||
}
|
||
output
|
||
}
|
||
|
||
/// 1D inverse DCT for N=8 (orthonormal).
|
||
fn idct1d_8(input: &[f64; 8]) -> [f64; 8] {
|
||
let mut output = [0.0f64; 8];
|
||
let n = BLOCK_SIZE as f64;
|
||
|
||
for i in 0..BLOCK_SIZE {
|
||
let mut sum = 0.0;
|
||
for k in 0..BLOCK_SIZE {
|
||
let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() };
|
||
sum += ck * input[k] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos();
|
||
}
|
||
output[i] = sum;
|
||
}
|
||
output
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn dct2_idct2_round_trip() {
|
||
let block: [f64; 64] = {
|
||
let mut b = [0.0; 64];
|
||
for i in 0..64 {
|
||
b[i] = (i as f64) * 3.7 - 100.0;
|
||
}
|
||
b
|
||
};
|
||
|
||
let dct = dct2_8x8(&block);
|
||
let recovered = idct2_8x8(&dct);
|
||
|
||
for i in 0..64 {
|
||
assert!(
|
||
(block[i] - recovered[i]).abs() < 1e-6,
|
||
"mismatch at index {i}: {} vs {}",
|
||
block[i],
|
||
recovered[i]
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn extract_y_channel_from_synthetic_jpeg() {
|
||
let jpeg_bytes = make_test_jpeg(64, 64);
|
||
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
|
||
assert_eq!(y_channel.width, 64);
|
||
assert_eq!(y_channel.height, 64);
|
||
assert_eq!(y_channel.data.len(), 64 * 64);
|
||
}
|
||
|
||
#[test]
|
||
fn get_blocks_from_region() {
|
||
let jpeg_bytes = make_test_jpeg(80, 80);
|
||
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
|
||
let region = central_region(&y_channel);
|
||
assert!(region.blocks_x > 0);
|
||
assert!(region.blocks_y > 0);
|
||
}
|
||
|
||
#[test]
|
||
fn read_write_block_round_trip() {
|
||
let jpeg_bytes = make_test_jpeg(80, 80);
|
||
let mut y_channel = extract_y_channel(&jpeg_bytes).unwrap();
|
||
let region = central_region(&y_channel);
|
||
|
||
let original = read_block(&y_channel, ®ion, 0, 0);
|
||
write_block(&mut y_channel, ®ion, 0, 0, &original);
|
||
let recovered = read_block(&y_channel, ®ion, 0, 0);
|
||
|
||
for i in 0..64 {
|
||
assert!((original[i] - recovered[i]).abs() < 1e-10);
|
||
}
|
||
}
|
||
|
||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
||
use image::codecs::jpeg::JpegEncoder;
|
||
use image::{ImageBuffer, ImageEncoder, Rgb};
|
||
|
||
let img = ImageBuffer::from_fn(width, height, |x, y| {
|
||
Rgb([
|
||
((x * 7 + y * 13) % 256) as u8,
|
||
((x * 11 + y * 3) % 256) as u8,
|
||
((x * 5 + y * 17) % 256) as u8,
|
||
])
|
||
});
|
||
|
||
let mut buf = Vec::new();
|
||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||
encoder
|
||
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
||
.unwrap();
|
||
buf
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update lib.rs**
|
||
|
||
```rust
|
||
// crates/idfoto-core/src/lib.rs
|
||
pub mod crypto;
|
||
pub mod entry;
|
||
pub mod error;
|
||
pub mod imgsecret;
|
||
pub mod vault;
|
||
|
||
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
|
||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||
pub use error::{IdfotoError, Result};
|
||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
Run: `cargo test -p idfoto-core imgsecret`
|
||
Expected: All 4 tests PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/
|
||
git commit -m "feat: add imgsecret JPEG decode, Y channel extraction, and 8x8 DCT"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: imgsecret — QIM Embedding + Block Selection
|
||
|
||
**Files:**
|
||
- Modify: `crates/idfoto-core/src/imgsecret.rs`
|
||
|
||
This task adds QIM (Quantization Index Modulation) for embedding/extracting individual bits in DCT coefficients, and the fixed geometric pattern for selecting which blocks carry data.
|
||
|
||
- [ ] **Step 1: Write tests for QIM and block selection**
|
||
|
||
Add to `mod tests` in `imgsecret.rs`:
|
||
|
||
```rust
|
||
#[test]
|
||
fn qim_embed_extract_single_bit() {
|
||
for coef in [-50.0, -10.0, 0.0, 10.0, 50.0, 127.0] {
|
||
for bit in [0u8, 1] {
|
||
let modified = qim_embed(coef, bit, QUANT_STEP);
|
||
let extracted = qim_extract(modified, QUANT_STEP);
|
||
assert_eq!(extracted, bit, "failed for coef={coef}, bit={bit}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn qim_survives_small_noise() {
|
||
let coef = 42.0;
|
||
let modified = qim_embed(coef, 1, QUANT_STEP);
|
||
// Add noise smaller than QUANT_STEP/4
|
||
let noisy = modified + 3.0;
|
||
let extracted = qim_extract(noisy, QUANT_STEP);
|
||
assert_eq!(extracted, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn select_embed_blocks_returns_consistent_pattern() {
|
||
let region = EmbedRegion {
|
||
x_offset: 12,
|
||
y_offset: 9,
|
||
region_width: 56,
|
||
region_height: 56,
|
||
blocks_x: 7,
|
||
blocks_y: 7,
|
||
};
|
||
|
||
let blocks1 = select_embed_blocks(®ion, 100);
|
||
let blocks2 = select_embed_blocks(®ion, 100);
|
||
assert_eq!(blocks1, blocks2, "pattern must be deterministic");
|
||
assert!(!blocks1.is_empty());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cargo test -p idfoto-core qim`
|
||
Expected: FAIL — `qim_embed`, `qim_extract`, `select_embed_blocks`, `QUANT_STEP` not defined.
|
||
|
||
- [ ] **Step 3: Write QIM and block selection implementation**
|
||
|
||
Add above the `#[cfg(test)]` block in `imgsecret.rs`:
|
||
|
||
```rust
|
||
/// QIM quantization step. Larger = more robust, more visible.
|
||
/// 25 survives JPEG recompression down to ~Q60 on most images.
|
||
const QUANT_STEP: f64 = 25.0;
|
||
|
||
/// Mid-frequency DCT coefficient positions in zig-zag order.
|
||
/// Positions 4-15: robust to recompression, visually undetectable.
|
||
/// Index into the 8×8 block as (row, col).
|
||
const EMBED_POSITIONS: [(usize, usize); 12] = [
|
||
(0, 3), // zig-zag position 4
|
||
(1, 2), // 5
|
||
(2, 1), // 6
|
||
(3, 0), // 7
|
||
(0, 4), // 8
|
||
(1, 3), // 9
|
||
(2, 2), // 10
|
||
(3, 1), // 11
|
||
(4, 0), // 12
|
||
(0, 5), // 13
|
||
(1, 4), // 14
|
||
(2, 3), // 15
|
||
];
|
||
|
||
/// Embed a single bit into a DCT coefficient using QIM.
|
||
///
|
||
/// Grid 0: values quantized to 0, Q, 2Q, 3Q, ...
|
||
/// Grid 1: values quantized to Q/2, 3Q/2, 5Q/2, ...
|
||
/// To embed bit b, quantize to grid b.
|
||
pub fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
||
let half_q = q / 2.0;
|
||
let offset = if bit == 1 { half_q } else { 0.0 };
|
||
let shifted = coef - offset;
|
||
let quantized = (shifted / q).round() * q;
|
||
quantized + offset
|
||
}
|
||
|
||
/// Extract a single bit from a DCT coefficient using QIM.
|
||
///
|
||
/// Measures distance to grid 0 and grid 1, returns whichever is closer.
|
||
pub fn qim_extract(coef: f64, q: f64) -> u8 {
|
||
let half_q = q / 2.0;
|
||
let dist0 = (coef - (coef / q).round() * q).abs();
|
||
let shifted = coef - half_q;
|
||
let dist1 = (shifted - (shifted / q).round() * q).abs();
|
||
if dist0 <= dist1 { 0 } else { 1 }
|
||
}
|
||
|
||
/// Select embedding block positions using a fixed geometric pattern.
|
||
///
|
||
/// Evenly spaced across the central region. Returns (bx, by) pairs.
|
||
/// `target_count` is the desired number of blocks; actual may be less
|
||
/// if the region is small.
|
||
pub fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
|
||
let total_blocks = region.blocks_x * region.blocks_y;
|
||
let count = target_count.min(total_blocks);
|
||
if count == 0 {
|
||
return vec![];
|
||
}
|
||
|
||
// Compute stride to evenly space `count` blocks across the grid
|
||
let stride = if count >= total_blocks {
|
||
1
|
||
} else {
|
||
total_blocks / count
|
||
};
|
||
|
||
let mut positions = Vec::with_capacity(count);
|
||
for i in (0..total_blocks).step_by(stride) {
|
||
let bx = i % region.blocks_x;
|
||
let by = i / region.blocks_x;
|
||
positions.push((bx, by));
|
||
if positions.len() >= count {
|
||
break;
|
||
}
|
||
}
|
||
positions
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
Run: `cargo test -p idfoto-core imgsecret`
|
||
Expected: All tests PASS (previous 4 + 3 new QIM/block-selection tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/imgsecret.rs
|
||
git commit -m "feat: add QIM bit embedding and fixed-pattern block selection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: imgsecret — Full embed() and extract()
|
||
|
||
**Files:**
|
||
- Modify: `crates/idfoto-core/src/imgsecret.rs`
|
||
|
||
This is the main event: the public `embed()` and `extract()` functions with redundancy coding and majority voting. Reed-Solomon is added in Task 10.
|
||
|
||
- [ ] **Step 1: Write the failing test for round-trip embed/extract**
|
||
|
||
Add to `mod tests`:
|
||
|
||
```rust
|
||
#[test]
|
||
fn embed_extract_round_trip() {
|
||
let jpeg_bytes = make_test_jpeg(400, 300);
|
||
let secret = [0xDEu8; 32];
|
||
|
||
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
|
||
let extracted = extract(&stego_jpeg).unwrap();
|
||
assert_eq!(extracted, secret);
|
||
}
|
||
|
||
#[test]
|
||
fn embed_extract_random_secret() {
|
||
let jpeg_bytes = make_test_jpeg(400, 300);
|
||
let mut secret = [0u8; 32];
|
||
rand::thread_rng().fill(&mut secret);
|
||
|
||
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
|
||
let extracted = extract(&stego_jpeg).unwrap();
|
||
assert_eq!(extracted, secret);
|
||
}
|
||
|
||
#[test]
|
||
fn extract_from_non_embedded_image_fails() {
|
||
let jpeg_bytes = make_test_jpeg(400, 300);
|
||
let result = extract(&jpeg_bytes);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn image_too_small_fails() {
|
||
let jpeg_bytes = make_test_jpeg(32, 32);
|
||
let secret = [0xABu8; 32];
|
||
let result = embed(&jpeg_bytes, &secret);
|
||
assert!(result.is_err());
|
||
}
|
||
```
|
||
|
||
Add `use rand::Fill;` at the top of the test module for the random fill.
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cargo test -p idfoto-core embed_extract`
|
||
Expected: FAIL — `embed` and `extract` not defined.
|
||
|
||
- [ ] **Step 3: Write embed() implementation**
|
||
|
||
Add to `imgsecret.rs`:
|
||
|
||
```rust
|
||
use image::codecs::jpeg::JpegEncoder;
|
||
use image::{ImageBuffer, ImageEncoder, Rgb};
|
||
|
||
/// Number of bits per redundant copy of the secret.
|
||
const SECRET_BITS: usize = 256; // 32 bytes
|
||
/// Minimum number of redundant copies for reliable extraction.
|
||
const MIN_COPIES: usize = 5;
|
||
/// Bits per embedding block (number of mid-freq coefficients used).
|
||
const BITS_PER_BLOCK: usize = EMBED_POSITIONS.len(); // 12
|
||
/// Blocks needed for one copy of the secret.
|
||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // ceil(256/12) = 22
|
||
/// Minimum image dimension for embedding.
|
||
const MIN_DIMENSION: u32 = 100;
|
||
|
||
/// Embed a 256-bit secret into a carrier JPEG.
|
||
/// Returns the modified JPEG bytes.
|
||
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||
let mut y = extract_y_channel(carrier_jpeg)?;
|
||
let region = central_region(&y);
|
||
|
||
// Check minimum size
|
||
if y.width < MIN_DIMENSION as usize || y.height < MIN_DIMENSION as usize {
|
||
return Err(IdfotoError::ImageTooSmall {
|
||
min_width: MIN_DIMENSION,
|
||
min_height: MIN_DIMENSION,
|
||
actual_width: y.width as u32,
|
||
actual_height: y.height as u32,
|
||
});
|
||
}
|
||
|
||
let total_blocks = region.blocks_x * region.blocks_y;
|
||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); // cap at 50 copies
|
||
|
||
if num_copies < MIN_COPIES {
|
||
return Err(IdfotoError::ImgSecret(format!(
|
||
"image too small for embedding: only {num_copies} copies fit, need at least {MIN_COPIES}"
|
||
)));
|
||
}
|
||
|
||
// Convert secret to bits
|
||
let secret_bits = bytes_to_bits(secret);
|
||
|
||
// Get all embed block positions
|
||
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
||
let positions = select_embed_blocks(®ion, blocks_needed);
|
||
|
||
// Embed each redundant copy
|
||
for copy_idx in 0..num_copies {
|
||
let block_start = copy_idx * BLOCKS_PER_COPY;
|
||
for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY]
|
||
.iter()
|
||
.enumerate()
|
||
{
|
||
let mut block = read_block(&y, ®ion, bx, by);
|
||
let mut dct = dct2_8x8(&block);
|
||
|
||
// Embed up to BITS_PER_BLOCK bits in this block
|
||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||
let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx;
|
||
if bit_idx < SECRET_BITS {
|
||
let coef_idx = row * BLOCK_SIZE + col;
|
||
dct[coef_idx] = qim_embed(dct[coef_idx], secret_bits[bit_idx], QUANT_STEP);
|
||
}
|
||
}
|
||
|
||
block = idct2_8x8(&dct);
|
||
write_block(&mut y, ®ion, bx, by, &block);
|
||
}
|
||
}
|
||
|
||
// Reconstruct full RGB image with modified Y channel and save as JPEG
|
||
reconstruct_jpeg(carrier_jpeg, &y)
|
||
}
|
||
|
||
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
|
||
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||
extract_at_offset(jpeg_bytes, 0, 0)
|
||
}
|
||
|
||
/// Try extraction at a specific pixel offset (for crop recovery).
|
||
fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]> {
|
||
let y = extract_y_channel(jpeg_bytes)?;
|
||
let mut region = central_region(&y);
|
||
|
||
// Apply offset for crop recovery
|
||
let new_x = region.x_offset as isize + dx;
|
||
let new_y = region.y_offset as isize + dy;
|
||
if new_x < 0 || new_y < 0 {
|
||
return Err(IdfotoError::ExtractionFailed);
|
||
}
|
||
region.x_offset = new_x as usize;
|
||
region.y_offset = new_y as usize;
|
||
|
||
// Recalculate blocks that fit
|
||
let avail_w = y.width.saturating_sub(region.x_offset);
|
||
let avail_h = y.height.saturating_sub(region.y_offset);
|
||
region.blocks_x = avail_w / BLOCK_SIZE;
|
||
region.blocks_y = avail_h / BLOCK_SIZE;
|
||
|
||
let total_blocks = region.blocks_x * region.blocks_y;
|
||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||
|
||
if num_copies < 1 {
|
||
return Err(IdfotoError::ExtractionFailed);
|
||
}
|
||
|
||
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
||
let positions = select_embed_blocks(®ion, blocks_needed);
|
||
|
||
// Extract all copies and majority-vote each bit
|
||
let mut bit_votes = vec![[0u32; 2]; SECRET_BITS]; // votes[bit_idx][0 or 1]
|
||
|
||
for copy_idx in 0..num_copies {
|
||
let block_start = copy_idx * BLOCKS_PER_COPY;
|
||
if block_start + BLOCKS_PER_COPY > positions.len() {
|
||
break;
|
||
}
|
||
|
||
for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY]
|
||
.iter()
|
||
.enumerate()
|
||
{
|
||
let block = read_block(&y, ®ion, bx, by);
|
||
let dct = dct2_8x8(&block);
|
||
|
||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||
let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx;
|
||
if bit_idx < SECRET_BITS {
|
||
let coef_idx = row * BLOCK_SIZE + col;
|
||
let bit = qim_extract(dct[coef_idx], QUANT_STEP);
|
||
bit_votes[bit_idx][bit as usize] += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Majority vote
|
||
let mut secret_bits = vec![0u8; SECRET_BITS];
|
||
let mut confidence = 0u32;
|
||
for (i, votes) in bit_votes.iter().enumerate() {
|
||
if votes[1] > votes[0] {
|
||
secret_bits[i] = 1;
|
||
confidence += votes[1];
|
||
} else {
|
||
confidence += votes[0];
|
||
}
|
||
}
|
||
|
||
// Confidence check: if votes are too evenly split, extraction likely failed
|
||
let total_votes: u32 = bit_votes.iter().map(|v| v[0] + v[1]).sum();
|
||
let min_confidence = total_votes * 3 / 4; // at least 75% of votes should agree
|
||
if confidence < min_confidence {
|
||
return Err(IdfotoError::ExtractionFailed);
|
||
}
|
||
|
||
Ok(bits_to_bytes(&secret_bits))
|
||
}
|
||
|
||
// ---- Helper functions ----
|
||
|
||
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||
for &byte in bytes {
|
||
for i in (0..8).rev() {
|
||
bits.push((byte >> i) & 1);
|
||
}
|
||
}
|
||
bits
|
||
}
|
||
|
||
fn bits_to_bytes(bits: &[u8]) -> [u8; 32] {
|
||
let mut bytes = [0u8; 32];
|
||
for (i, chunk) in bits.chunks(8).enumerate() {
|
||
if i >= 32 {
|
||
break;
|
||
}
|
||
let mut byte = 0u8;
|
||
for (j, &bit) in chunk.iter().enumerate() {
|
||
byte |= bit << (7 - j);
|
||
}
|
||
bytes[i] = byte;
|
||
}
|
||
bytes
|
||
}
|
||
|
||
/// Reconstruct JPEG from original image with modified Y channel.
|
||
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
|
||
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
||
.with_guessed_format()
|
||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
||
|
||
let img = reader
|
||
.decode()
|
||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||
|
||
let rgb = img.to_rgb8();
|
||
let (width, height) = (rgb.width(), rgb.height());
|
||
|
||
// Reconstruct RGB: replace Y while keeping original Cb/Cr
|
||
let mut output: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::new(width, height);
|
||
|
||
for (x, y_pos, pixel) in rgb.enumerate_pixels() {
|
||
let idx = y_pos as usize * width as usize + x as usize;
|
||
let orig_r = pixel[0] as f64;
|
||
let orig_g = pixel[1] as f64;
|
||
let orig_b = pixel[2] as f64;
|
||
|
||
// Original YCbCr
|
||
let orig_y = 0.299 * orig_r + 0.587 * orig_g + 0.114 * orig_b;
|
||
let cb = 128.0 - 0.168736 * orig_r - 0.331264 * orig_g + 0.5 * orig_b;
|
||
let cr = 128.0 + 0.5 * orig_r - 0.418688 * orig_g - 0.081312 * orig_b;
|
||
|
||
// Modified Y
|
||
let new_y = y_modified.data[idx];
|
||
|
||
// YCbCr → RGB with new Y
|
||
let r = (new_y + 1.402 * (cr - 128.0)).clamp(0.0, 255.0) as u8;
|
||
let g = (new_y - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0)).clamp(0.0, 255.0) as u8;
|
||
let b = (new_y + 1.772 * (cb - 128.0)).clamp(0.0, 255.0) as u8;
|
||
|
||
output.put_pixel(x, y_pos, Rgb([r, g, b]));
|
||
}
|
||
|
||
// Encode as JPEG at quality 92
|
||
let mut buf = Vec::new();
|
||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||
encoder
|
||
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
|
||
Ok(buf)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
Run: `cargo test -p idfoto-core imgsecret -- --nocapture`
|
||
Expected: All tests PASS including embed/extract round-trip.
|
||
|
||
- [ ] **Step 5: Add a JPEG recompression survival test**
|
||
|
||
Add to `mod tests`:
|
||
|
||
```rust
|
||
#[test]
|
||
fn embed_extract_survives_recompression_q85() {
|
||
let jpeg_bytes = make_test_jpeg(400, 300);
|
||
let secret = [0xCAu8; 32];
|
||
|
||
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
|
||
|
||
// Re-encode at Q85 (simulating social media)
|
||
let reader = image::io::Reader::new(Cursor::new(&stego_jpeg))
|
||
.with_guessed_format()
|
||
.unwrap();
|
||
let img = reader.decode().unwrap();
|
||
let rgb = img.to_rgb8();
|
||
let (w, h) = (rgb.width(), rgb.height());
|
||
|
||
let mut recompressed = Vec::new();
|
||
let encoder = JpegEncoder::new_with_quality(&mut recompressed, 85);
|
||
encoder
|
||
.write_image(rgb.as_raw(), w, h, image::ExtendedColorType::Rgb8)
|
||
.unwrap();
|
||
|
||
let extracted = extract(&recompressed).unwrap();
|
||
assert_eq!(extracted, secret, "secret must survive Q85 recompression");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run all tests**
|
||
|
||
Run: `cargo test -p idfoto-core`
|
||
Expected: All tests PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/imgsecret.rs
|
||
git commit -m "feat: add imgsecret embed/extract with redundancy and majority voting"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: imgsecret — Crop Recovery
|
||
|
||
**Files:**
|
||
- Modify: `crates/idfoto-core/src/imgsecret.rs`
|
||
|
||
- [ ] **Step 1: Write failing crop test**
|
||
|
||
Add to `mod tests`:
|
||
|
||
```rust
|
||
#[test]
|
||
fn embed_extract_survives_10pct_crop() {
|
||
let jpeg_bytes = make_test_jpeg(400, 300);
|
||
let secret = [0xBBu8; 32];
|
||
|
||
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
|
||
|
||
// Crop 10% from the right edge
|
||
let reader = image::io::Reader::new(Cursor::new(&stego_jpeg))
|
||
.with_guessed_format()
|
||
.unwrap();
|
||
let img = reader.decode().unwrap();
|
||
let (w, h) = (img.width(), img.height());
|
||
let crop_pixels = w * 10 / 100;
|
||
let cropped = img.crop_imm(0, 0, w - crop_pixels, h);
|
||
let rgb = cropped.to_rgb8();
|
||
|
||
let mut cropped_jpeg = Vec::new();
|
||
let encoder = JpegEncoder::new_with_quality(&mut cropped_jpeg, 92);
|
||
encoder
|
||
.write_image(
|
||
rgb.as_raw(),
|
||
rgb.width(),
|
||
rgb.height(),
|
||
image::ExtendedColorType::Rgb8,
|
||
)
|
||
.unwrap();
|
||
|
||
let extracted = extract_with_crop_recovery(&cropped_jpeg).unwrap();
|
||
assert_eq!(extracted, secret, "secret must survive 10% crop");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cargo test -p idfoto-core crop`
|
||
Expected: FAIL — `extract_with_crop_recovery` not defined.
|
||
|
||
- [ ] **Step 3: Write crop recovery implementation**
|
||
|
||
Add to `imgsecret.rs`:
|
||
|
||
```rust
|
||
/// Extract with crop recovery — tries multiple offsets if canonical extraction fails.
|
||
pub fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||
// Try canonical alignment first (fast path)
|
||
if let Ok(secret) = extract(jpeg_bytes) {
|
||
return Ok(secret);
|
||
}
|
||
|
||
// Determine search range from image dimensions
|
||
let y = extract_y_channel(jpeg_bytes)?;
|
||
let max_dx = (y.width as isize) * 15 / 100;
|
||
let max_dy = (y.height as isize) * 15 / 100;
|
||
let step = BLOCK_SIZE as isize; // 8 pixels
|
||
|
||
// Search crop offsets
|
||
for dy in (-max_dy..=max_dy).step_by(step as usize) {
|
||
for dx in (-max_dx..=max_dx).step_by(step as usize) {
|
||
if dx == 0 && dy == 0 {
|
||
continue; // already tried canonical
|
||
}
|
||
if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) {
|
||
return Ok(secret);
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(IdfotoError::ExtractionFailed)
|
||
}
|
||
```
|
||
|
||
Also update the public `extract()` to call `extract_with_crop_recovery()`:
|
||
|
||
Replace the existing `extract` function:
|
||
|
||
```rust
|
||
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
|
||
/// Automatically tries crop recovery if canonical extraction fails.
|
||
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||
extract_with_crop_recovery(jpeg_bytes)
|
||
}
|
||
|
||
/// Internal: try extraction at canonical (0,0) offset only.
|
||
fn extract_canonical(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||
extract_at_offset(jpeg_bytes, 0, 0)
|
||
}
|
||
|
||
/// Extract with crop recovery — tries multiple offsets if canonical extraction fails.
|
||
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||
// Try canonical alignment first (fast path)
|
||
if let Ok(secret) = extract_canonical(jpeg_bytes) {
|
||
return Ok(secret);
|
||
}
|
||
|
||
// Determine search range from image dimensions
|
||
let y = extract_y_channel(jpeg_bytes)?;
|
||
let max_dx = (y.width as isize) * 15 / 100;
|
||
let max_dy = (y.height as isize) * 15 / 100;
|
||
let step = BLOCK_SIZE as isize;
|
||
|
||
for dy in (-max_dy..=max_dy).step_by(step as usize) {
|
||
for dx in (-max_dx..=max_dx).step_by(step as usize) {
|
||
if dx == 0 && dy == 0 {
|
||
continue;
|
||
}
|
||
if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) {
|
||
return Ok(secret);
|
||
}
|
||
}
|
||
}
|
||
|
||
Err(IdfotoError::ExtractionFailed)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run all imgsecret tests**
|
||
|
||
Run: `cargo test -p idfoto-core imgsecret -- --nocapture`
|
||
Expected: All tests PASS including crop recovery.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/src/imgsecret.rs
|
||
git commit -m "feat: add crop recovery with multi-offset extraction search"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: CLI — Scaffolding, init, generate
|
||
|
||
**Files:**
|
||
- Modify: `crates/idfoto-cli/src/main.rs`
|
||
|
||
- [ ] **Step 1: Write the clap CLI structure**
|
||
|
||
```rust
|
||
// crates/idfoto-cli/src/main.rs
|
||
use anyhow::{Context, Result};
|
||
use clap::{Parser, Subcommand};
|
||
use idfoto_core::{
|
||
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
|
||
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
|
||
};
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Command;
|
||
|
||
#[derive(Parser)]
|
||
#[command(name = "idfoto", version, about = "Git-backed password manager with reference image authentication")]
|
||
struct Cli {
|
||
#[command(subcommand)]
|
||
command: Commands,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum Commands {
|
||
/// Create a new vault
|
||
Init {
|
||
/// Path to carrier JPEG image
|
||
#[arg(long)]
|
||
image: PathBuf,
|
||
/// Output path for the reference JPEG (with embedded secret)
|
||
#[arg(long, default_value = "reference.jpg")]
|
||
output: PathBuf,
|
||
},
|
||
/// Add a new credential
|
||
Add,
|
||
/// Get a credential by name
|
||
Get {
|
||
/// Name or URL to search for
|
||
name: String,
|
||
},
|
||
/// List all credentials
|
||
List,
|
||
/// Edit a credential
|
||
Edit {
|
||
/// Name or URL to search for
|
||
name: String,
|
||
},
|
||
/// Remove a credential
|
||
Rm {
|
||
/// Name or URL to search for
|
||
name: String,
|
||
},
|
||
/// Sync with remote (git pull + push)
|
||
Sync,
|
||
/// Generate a random password
|
||
Generate {
|
||
/// Password length
|
||
#[arg(short, long, default_value = "20")]
|
||
length: usize,
|
||
},
|
||
}
|
||
|
||
fn main() -> Result<()> {
|
||
let cli = Cli::parse();
|
||
|
||
match cli.command {
|
||
Commands::Init { image, output } => cmd_init(&image, &output),
|
||
Commands::Add => cmd_add(),
|
||
Commands::Get { name } => cmd_get(&name),
|
||
Commands::List => cmd_list(),
|
||
Commands::Edit { name } => cmd_edit(&name),
|
||
Commands::Rm { name } => cmd_rm(&name),
|
||
Commands::Sync => cmd_sync(),
|
||
Commands::Generate { length } => cmd_generate(length),
|
||
}
|
||
}
|
||
|
||
fn cmd_generate(length: usize) -> Result<()> {
|
||
use rand::Rng;
|
||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
||
let mut rng = rand::thread_rng();
|
||
let password: String = (0..length)
|
||
.map(|_| {
|
||
let idx = rng.gen_range(0..CHARSET.len());
|
||
CHARSET[idx] as char
|
||
})
|
||
.collect();
|
||
println!("{password}");
|
||
Ok(())
|
||
}
|
||
|
||
// ---- Vault helpers ----
|
||
|
||
/// Path to the vault root (current directory by default).
|
||
fn vault_dir() -> PathBuf {
|
||
PathBuf::from(".")
|
||
}
|
||
|
||
fn idfoto_dir() -> PathBuf {
|
||
vault_dir().join(".idfoto")
|
||
}
|
||
|
||
fn read_salt() -> Result<[u8; 32]> {
|
||
let bytes = fs::read(idfoto_dir().join("salt"))
|
||
.context("failed to read .idfoto/salt — is this a vault directory?")?;
|
||
let mut salt = [0u8; 32];
|
||
salt.copy_from_slice(&bytes);
|
||
Ok(salt)
|
||
}
|
||
|
||
fn read_params() -> Result<KdfParams> {
|
||
let json = fs::read_to_string(idfoto_dir().join("params.json"))
|
||
.context("failed to read .idfoto/params.json")?;
|
||
Ok(serde_json::from_str(&json)?)
|
||
}
|
||
|
||
/// Prompt for passphrase and reference image, derive master_key.
|
||
fn unlock(image_path: &Path) -> Result<[u8; 32]> {
|
||
let passphrase = rpassword::prompt_password("Passphrase: ")
|
||
.context("failed to read passphrase")?;
|
||
|
||
let jpeg_bytes = fs::read(image_path)
|
||
.context("failed to read reference image")?;
|
||
|
||
let image_secret = idfoto_core::imgsecret::extract(&jpeg_bytes)
|
||
.map_err(|e| anyhow::anyhow!("failed to extract image secret: {e}"))?;
|
||
|
||
let salt = read_salt()?;
|
||
let params = read_params()?;
|
||
|
||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
|
||
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
|
||
|
||
Ok(master_key)
|
||
}
|
||
|
||
/// Get reference image path — from env var IDFOTO_IMAGE or prompt.
|
||
fn get_image_path() -> Result<PathBuf> {
|
||
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
||
return Ok(PathBuf::from(path));
|
||
}
|
||
eprint!("Reference image path: ");
|
||
let mut input = String::new();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
Ok(PathBuf::from(input.trim()))
|
||
}
|
||
|
||
fn read_manifest(master_key: &[u8; 32]) -> Result<Manifest> {
|
||
let data = fs::read(vault_dir().join("manifest.enc"))
|
||
.context("failed to read manifest.enc")?;
|
||
decrypt_manifest(master_key, &data)
|
||
.map_err(|e| anyhow::anyhow!("failed to decrypt manifest: {e}"))
|
||
}
|
||
|
||
fn write_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<()> {
|
||
let data = encrypt_manifest(master_key, manifest)
|
||
.map_err(|e| anyhow::anyhow!("failed to encrypt manifest: {e}"))?;
|
||
fs::write(vault_dir().join("manifest.enc"), data)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn git_commit(message: &str) -> Result<()> {
|
||
Command::new("git")
|
||
.args(["add", "-A"])
|
||
.status()
|
||
.context("git add failed")?;
|
||
Command::new("git")
|
||
.args(["commit", "-m", message])
|
||
.status()
|
||
.context("git commit failed")?;
|
||
Ok(())
|
||
}
|
||
|
||
fn now_iso8601() -> String {
|
||
// Simple UTC timestamp without chrono dependency
|
||
use std::time::SystemTime;
|
||
let duration = SystemTime::now()
|
||
.duration_since(SystemTime::UNIX_EPOCH)
|
||
.unwrap();
|
||
let secs = duration.as_secs();
|
||
// Rough ISO 8601 — good enough for a password manager
|
||
format!("{secs}")
|
||
}
|
||
|
||
// ---- Command implementations ----
|
||
|
||
fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
|
||
// 1. Read carrier image
|
||
let carrier_jpeg = fs::read(image_path)
|
||
.context("failed to read carrier image")?;
|
||
|
||
// 2. Generate image_secret and embed
|
||
let mut image_secret = [0u8; 32];
|
||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret);
|
||
|
||
println!("Embedding secret into reference image...");
|
||
let stego_jpeg = idfoto_core::imgsecret::embed(&carrier_jpeg, &image_secret)
|
||
.map_err(|e| anyhow::anyhow!("failed to embed secret: {e}"))?;
|
||
fs::write(output_path, &stego_jpeg)
|
||
.context("failed to write reference image")?;
|
||
println!("Reference image saved to: {}", output_path.display());
|
||
|
||
// 3. Prompt for passphrase
|
||
let passphrase = rpassword::prompt_password("Choose a passphrase: ")?;
|
||
let passphrase_confirm = rpassword::prompt_password("Confirm passphrase: ")?;
|
||
if passphrase != passphrase_confirm {
|
||
anyhow::bail!("passphrases do not match");
|
||
}
|
||
if passphrase.len() < 8 {
|
||
anyhow::bail!("passphrase must be at least 8 characters");
|
||
}
|
||
|
||
// 4. Generate salt and derive key
|
||
let mut salt = [0u8; 32];
|
||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
|
||
let params = KdfParams::default();
|
||
|
||
println!("Deriving master key (this may take a moment)...");
|
||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
|
||
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
|
||
|
||
// 5. Write vault structure
|
||
fs::create_dir_all(idfoto_dir())?;
|
||
fs::create_dir_all(vault_dir().join("entries"))?;
|
||
fs::write(idfoto_dir().join("salt"), salt)?;
|
||
fs::write(
|
||
idfoto_dir().join("params.json"),
|
||
serde_json::to_string_pretty(¶ms)?,
|
||
)?;
|
||
fs::write(idfoto_dir().join("devices.json"), "[]")?;
|
||
|
||
// 6. Write empty manifest
|
||
let manifest = Manifest::new();
|
||
write_manifest(&master_key, &manifest)?;
|
||
|
||
// 7. Git init + commit
|
||
Command::new("git").args(["init"]).status()?;
|
||
|
||
// Add .gitignore
|
||
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")?;
|
||
|
||
git_commit("feat: initialize idfoto vault")?;
|
||
|
||
println!("\nVault initialized successfully!");
|
||
println!("IMPORTANT: Keep your reference image ({}) safe — you need it to unlock the vault.", output_path.display());
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_add() -> Result<()> {
|
||
let image_path = get_image_path()?;
|
||
let master_key = unlock(&image_path)?;
|
||
let mut manifest = read_manifest(&master_key)?;
|
||
|
||
// Prompt for entry fields
|
||
let name = prompt("Name (e.g., GitHub): ")?;
|
||
let url = prompt_optional("URL: ")?;
|
||
let username = prompt_optional("Username: ")?;
|
||
let password = rpassword::prompt_password("Password (or press Enter to generate): ")?;
|
||
let password = if password.is_empty() {
|
||
let p = generate_password(20);
|
||
println!("Generated: {p}");
|
||
p
|
||
} else {
|
||
password
|
||
};
|
||
let notes = prompt_optional("Notes: ")?;
|
||
let totp = prompt_optional("TOTP secret: ")?;
|
||
|
||
let now = now_iso8601();
|
||
let entry = Entry {
|
||
name: name.clone(),
|
||
url: url.clone(),
|
||
username: username.clone(),
|
||
password,
|
||
notes,
|
||
totp_secret: totp,
|
||
created_at: now.clone(),
|
||
updated_at: now.clone(),
|
||
};
|
||
|
||
let entry_id = generate_entry_id();
|
||
let encrypted = encrypt_entry(&master_key, &entry)
|
||
.map_err(|e| anyhow::anyhow!("failed to encrypt entry: {e}"))?;
|
||
|
||
fs::write(vault_dir().join("entries").join(format!("{entry_id}.enc")), encrypted)?;
|
||
|
||
manifest.add_entry(
|
||
entry_id.clone(),
|
||
ManifestEntry {
|
||
name: name.clone(),
|
||
url,
|
||
username,
|
||
updated_at: now,
|
||
},
|
||
);
|
||
write_manifest(&master_key, &manifest)?;
|
||
|
||
git_commit(&format!("add: {name}"))?;
|
||
println!("Added entry: {name} ({entry_id})");
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_get(query: &str) -> Result<()> {
|
||
let image_path = get_image_path()?;
|
||
let master_key = unlock(&image_path)?;
|
||
let manifest = read_manifest(&master_key)?;
|
||
|
||
let results = manifest.search(query);
|
||
if results.is_empty() {
|
||
anyhow::bail!("no entries matching '{query}'");
|
||
}
|
||
|
||
let (id, meta) = if results.len() == 1 {
|
||
results[0]
|
||
} else {
|
||
println!("Multiple matches:");
|
||
for (i, (id, e)) in results.iter().enumerate() {
|
||
println!(" {}: {} ({})", i + 1, e.name, id);
|
||
}
|
||
let choice = prompt("Choose (number): ")?;
|
||
let idx: usize = choice.trim().parse().context("invalid number")?;
|
||
results.get(idx - 1).context("invalid choice")?
|
||
};
|
||
|
||
let entry_data = fs::read(vault_dir().join("entries").join(format!("{id}.enc")))?;
|
||
let entry = decrypt_entry(&master_key, &entry_data)
|
||
.map_err(|e| anyhow::anyhow!("failed to decrypt entry: {e}"))?;
|
||
|
||
println!("Name: {}", entry.name);
|
||
if let Some(ref url) = entry.url {
|
||
println!("URL: {url}");
|
||
}
|
||
if let Some(ref user) = entry.username {
|
||
println!("Username: {user}");
|
||
}
|
||
println!("Password: {}", entry.password);
|
||
if let Some(ref notes) = entry.notes {
|
||
println!("Notes: {notes}");
|
||
}
|
||
|
||
// Copy to clipboard
|
||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||
if clipboard.set_text(&entry.password).is_ok() {
|
||
println!("\n(Password copied to clipboard — clears in 30s)");
|
||
std::thread::spawn(move || {
|
||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||
let _ = cb.set_text("");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_list() -> Result<()> {
|
||
let image_path = get_image_path()?;
|
||
let master_key = unlock(&image_path)?;
|
||
let manifest = read_manifest(&master_key)?;
|
||
|
||
if manifest.entries.is_empty() {
|
||
println!("Vault is empty.");
|
||
return Ok(());
|
||
}
|
||
|
||
let mut entries: Vec<_> = manifest.entries.iter().collect();
|
||
entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()));
|
||
|
||
for (id, entry) in entries {
|
||
let url = entry.url.as_deref().unwrap_or("-");
|
||
let user = entry.username.as_deref().unwrap_or("-");
|
||
println!("{id} {:<20} {:<30} {user}", entry.name, url);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_edit(query: &str) -> Result<()> {
|
||
let image_path = get_image_path()?;
|
||
let master_key = unlock(&image_path)?;
|
||
let mut manifest = read_manifest(&master_key)?;
|
||
|
||
let results = manifest.search(query);
|
||
if results.is_empty() {
|
||
anyhow::bail!("no entries matching '{query}'");
|
||
}
|
||
let (id, _) = results[0];
|
||
let id = id.clone();
|
||
|
||
let entry_data = fs::read(vault_dir().join("entries").join(format!("{id}.enc")))?;
|
||
let mut entry = decrypt_entry(&master_key, &entry_data)
|
||
.map_err(|e| anyhow::anyhow!("failed to decrypt entry: {e}"))?;
|
||
|
||
println!("Editing: {} (press Enter to keep current value)", entry.name);
|
||
entry.name = prompt_with_default("Name", &entry.name)?;
|
||
entry.url = Some(prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty());
|
||
entry.username = Some(prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty());
|
||
let new_pass = rpassword::prompt_password("Password (Enter to keep): ")?;
|
||
if !new_pass.is_empty() {
|
||
entry.password = new_pass;
|
||
}
|
||
entry.notes = Some(prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty());
|
||
entry.updated_at = now_iso8601();
|
||
|
||
let encrypted = encrypt_entry(&master_key, &entry)
|
||
.map_err(|e| anyhow::anyhow!("failed to encrypt entry: {e}"))?;
|
||
fs::write(vault_dir().join("entries").join(format!("{id}.enc")), encrypted)?;
|
||
|
||
// Update manifest
|
||
if let Some(me) = manifest.entries.get_mut(&id) {
|
||
me.name = entry.name.clone();
|
||
me.url = entry.url.clone();
|
||
me.username = entry.username.clone();
|
||
me.updated_at = entry.updated_at.clone();
|
||
}
|
||
write_manifest(&master_key, &manifest)?;
|
||
|
||
git_commit(&format!("edit: {}", entry.name))?;
|
||
println!("Updated: {}", entry.name);
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_rm(query: &str) -> Result<()> {
|
||
let image_path = get_image_path()?;
|
||
let master_key = unlock(&image_path)?;
|
||
let mut manifest = read_manifest(&master_key)?;
|
||
|
||
let results = manifest.search(query);
|
||
if results.is_empty() {
|
||
anyhow::bail!("no entries matching '{query}'");
|
||
}
|
||
let (id, meta) = results[0];
|
||
let id = id.clone();
|
||
let name = meta.name.clone();
|
||
|
||
let confirm = prompt(&format!("Delete '{name}'? (y/N): "))?;
|
||
if confirm.trim().to_lowercase() != "y" {
|
||
println!("Cancelled.");
|
||
return Ok(());
|
||
}
|
||
|
||
fs::remove_file(vault_dir().join("entries").join(format!("{id}.enc")))?;
|
||
manifest.remove_entry(&id);
|
||
write_manifest(&master_key, &manifest)?;
|
||
|
||
git_commit(&format!("rm: {name}"))?;
|
||
println!("Deleted: {name}");
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_sync() -> Result<()> {
|
||
println!("Pulling...");
|
||
let pull = Command::new("git")
|
||
.args(["pull", "--rebase"])
|
||
.status()
|
||
.context("git pull failed")?;
|
||
if !pull.success() {
|
||
anyhow::bail!("git pull failed");
|
||
}
|
||
|
||
println!("Pushing...");
|
||
let push = Command::new("git")
|
||
.args(["push"])
|
||
.status()
|
||
.context("git push failed")?;
|
||
if !push.success() {
|
||
anyhow::bail!("git push failed");
|
||
}
|
||
|
||
println!("Synced.");
|
||
Ok(())
|
||
}
|
||
|
||
// ---- Terminal helpers ----
|
||
|
||
fn prompt(message: &str) -> Result<String> {
|
||
eprint!("{message}");
|
||
let mut input = String::new();
|
||
std::io::stdin().read_line(&mut input)?;
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||
let input = prompt(message)?;
|
||
Ok(if input.is_empty() { None } else { Some(input) })
|
||
}
|
||
|
||
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
||
let display = if current.is_empty() {
|
||
format!("{field}: ")
|
||
} else {
|
||
format!("{field} [{current}]: ")
|
||
};
|
||
let input = prompt(&display)?;
|
||
Ok(if input.is_empty() {
|
||
current.to_string()
|
||
} else {
|
||
input
|
||
})
|
||
}
|
||
|
||
fn generate_password(length: usize) -> String {
|
||
use rand::Rng;
|
||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
||
let mut rng = rand::thread_rng();
|
||
(0..length)
|
||
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
|
||
.collect()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify build**
|
||
|
||
Run: `cargo build`
|
||
Expected: Compiles with no errors.
|
||
|
||
- [ ] **Step 3: Test generate command**
|
||
|
||
Run: `cargo run -- generate`
|
||
Expected: Prints a random 20-character password.
|
||
|
||
Run: `cargo run -- generate -l 32`
|
||
Expected: Prints a random 32-character password.
|
||
|
||
- [ ] **Step 4: Test help output**
|
||
|
||
Run: `cargo run -- --help`
|
||
Expected: Shows all subcommands with descriptions.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-cli/src/main.rs
|
||
git commit -m "feat: add full CLI with init, add, get, list, edit, rm, sync, generate"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: CLI — Device Management
|
||
|
||
**Files:**
|
||
- Modify: `crates/idfoto-cli/src/main.rs`
|
||
|
||
- [ ] **Step 1: Add device subcommands to the CLI**
|
||
|
||
Add to the `Commands` enum:
|
||
|
||
```rust
|
||
/// Manage trusted devices
|
||
Device {
|
||
#[command(subcommand)]
|
||
action: DeviceCommands,
|
||
},
|
||
```
|
||
|
||
Add the subcommand enum:
|
||
|
||
```rust
|
||
#[derive(Subcommand)]
|
||
enum DeviceCommands {
|
||
/// Register this device
|
||
Add {
|
||
/// Device name
|
||
#[arg(long)]
|
||
name: String,
|
||
},
|
||
/// List authorized devices
|
||
List,
|
||
/// Revoke a device
|
||
Revoke {
|
||
/// Device name to revoke
|
||
name: String,
|
||
},
|
||
}
|
||
```
|
||
|
||
Add the match arm in `main()`:
|
||
|
||
```rust
|
||
Commands::Device { action } => match action {
|
||
DeviceCommands::Add { name } => cmd_device_add(&name),
|
||
DeviceCommands::List => cmd_device_list(),
|
||
DeviceCommands::Revoke { name } => cmd_device_revoke(&name),
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: Write device management functions**
|
||
|
||
Add to `main.rs`:
|
||
|
||
```rust
|
||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
struct DeviceEntry {
|
||
name: String,
|
||
public_key: String, // hex-encoded ed25519 public key
|
||
}
|
||
|
||
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
||
let json = fs::read_to_string(idfoto_dir().join("devices.json"))
|
||
.context("failed to read devices.json")?;
|
||
Ok(serde_json::from_str(&json)?)
|
||
}
|
||
|
||
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
||
let json = serde_json::to_string_pretty(devices)?;
|
||
fs::write(idfoto_dir().join("devices.json"), json)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_device_add(name: &str) -> Result<()> {
|
||
let mut devices = read_devices()?;
|
||
|
||
if devices.iter().any(|d| d.name == name) {
|
||
anyhow::bail!("device '{name}' already registered");
|
||
}
|
||
|
||
// Generate ed25519 keypair
|
||
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||
let verifying_key: VerifyingKey = (&signing_key).into();
|
||
let pubkey_hex = hex::encode(verifying_key.as_bytes());
|
||
|
||
// Save private key to local config
|
||
let config_dir = dirs::config_dir()
|
||
.context("no config directory")?
|
||
.join("idfoto");
|
||
fs::create_dir_all(&config_dir)?;
|
||
fs::write(
|
||
config_dir.join(format!("{name}.key")),
|
||
hex::encode(signing_key.to_bytes()),
|
||
)?;
|
||
|
||
devices.push(DeviceEntry {
|
||
name: name.to_string(),
|
||
public_key: pubkey_hex.clone(),
|
||
});
|
||
write_devices(&devices)?;
|
||
|
||
git_commit(&format!("device: add {name}"))?;
|
||
println!("Device '{name}' registered (pubkey: {pubkey_hex})");
|
||
println!("Private key saved to: {}", config_dir.join(format!("{name}.key")).display());
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_device_list() -> Result<()> {
|
||
let devices = read_devices()?;
|
||
if devices.is_empty() {
|
||
println!("No devices registered.");
|
||
return Ok(());
|
||
}
|
||
for d in &devices {
|
||
println!(" {} — {}", d.name, &d.public_key[..16]);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_device_revoke(name: &str) -> Result<()> {
|
||
let mut devices = read_devices()?;
|
||
let before = devices.len();
|
||
devices.retain(|d| d.name != name);
|
||
if devices.len() == before {
|
||
anyhow::bail!("device '{name}' not found");
|
||
}
|
||
write_devices(&devices)?;
|
||
git_commit(&format!("device: revoke {name}"))?;
|
||
println!("Device '{name}' revoked.");
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add hex dependency**
|
||
|
||
Add to `crates/idfoto-cli/Cargo.toml` under `[dependencies]`:
|
||
|
||
```toml
|
||
hex = "0.4"
|
||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||
rand = "0.8"
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
```
|
||
|
||
- [ ] **Step 4: Verify build**
|
||
|
||
Run: `cargo build`
|
||
Expected: Compiles cleanly.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-cli/
|
||
git commit -m "feat: add device add/list/revoke commands with ed25519 key management"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Integration Test — Full Vault Workflow
|
||
|
||
**Files:**
|
||
- Create: `crates/idfoto-core/tests/integration.rs`
|
||
|
||
This test exercises the full flow: generate secret → embed → derive key → encrypt entry → decrypt entry → extract secret from re-encoded image.
|
||
|
||
- [ ] **Step 1: Write the integration test**
|
||
|
||
```rust
|
||
// crates/idfoto-core/tests/integration.rs
|
||
use idfoto_core::*;
|
||
use idfoto_core::imgsecret;
|
||
|
||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
||
use image::codecs::jpeg::JpegEncoder;
|
||
use image::{ImageBuffer, ImageEncoder, Rgb};
|
||
|
||
let img = ImageBuffer::from_fn(width, height, |x, y| {
|
||
Rgb([
|
||
((x * 7 + y * 13) % 256) as u8,
|
||
((x * 11 + y * 3) % 256) as u8,
|
||
((x * 5 + y * 17) % 256) as u8,
|
||
])
|
||
});
|
||
|
||
let mut buf = Vec::new();
|
||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||
encoder
|
||
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
||
.unwrap();
|
||
buf
|
||
}
|
||
|
||
#[test]
|
||
fn full_vault_workflow() {
|
||
// 1. Generate an image_secret and embed it
|
||
let carrier = make_test_jpeg(400, 300);
|
||
let mut image_secret = [0u8; 32];
|
||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret);
|
||
|
||
let reference_jpeg = imgsecret::embed(&carrier, &image_secret).unwrap();
|
||
|
||
// 2. Extract the secret back
|
||
let extracted_secret = imgsecret::extract(&reference_jpeg).unwrap();
|
||
assert_eq!(extracted_secret, image_secret);
|
||
|
||
// 3. Derive master key
|
||
let passphrase = b"correct horse battery staple";
|
||
let mut salt = [0u8; 32];
|
||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
|
||
let params = KdfParams {
|
||
argon2_m: 256,
|
||
argon2_t: 1,
|
||
argon2_p: 1,
|
||
};
|
||
let master_key = derive_master_key(passphrase, &extracted_secret, &salt, ¶ms).unwrap();
|
||
|
||
// 4. Create and encrypt an entry
|
||
let entry = Entry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
password: "supersecret123!".into(),
|
||
notes: None,
|
||
totp_secret: None,
|
||
created_at: "2026-04-11T00:00:00Z".into(),
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
};
|
||
|
||
let encrypted = encrypt_entry(&master_key, &entry).unwrap();
|
||
|
||
// 5. Decrypt and verify
|
||
let decrypted = decrypt_entry(&master_key, &encrypted).unwrap();
|
||
assert_eq!(decrypted.name, "GitHub");
|
||
assert_eq!(decrypted.password, "supersecret123!");
|
||
assert_eq!(decrypted.username.as_deref(), Some("alee"));
|
||
|
||
// 6. Wrong passphrase must fail
|
||
let wrong_key = derive_master_key(b"wrong", &extracted_secret, &salt, ¶ms).unwrap();
|
||
assert!(decrypt_entry(&wrong_key, &encrypted).is_err());
|
||
|
||
// 7. Wrong image_secret must fail
|
||
let wrong_secret = [0xFFu8; 32];
|
||
let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, ¶ms).unwrap();
|
||
assert!(decrypt_entry(&wrong_key2, &encrypted).is_err());
|
||
|
||
// 8. Manifest round-trip
|
||
let mut manifest = Manifest::new();
|
||
manifest.add_entry(
|
||
"abc123".into(),
|
||
ManifestEntry {
|
||
name: "GitHub".into(),
|
||
url: Some("https://github.com".into()),
|
||
username: Some("alee".into()),
|
||
updated_at: "2026-04-11T00:00:00Z".into(),
|
||
},
|
||
);
|
||
|
||
let enc_manifest = encrypt_manifest(&master_key, &manifest).unwrap();
|
||
let dec_manifest = decrypt_manifest(&master_key, &enc_manifest).unwrap();
|
||
assert_eq!(dec_manifest.entries.len(), 1);
|
||
assert!(dec_manifest.entries.contains_key("abc123"));
|
||
}
|
||
|
||
#[test]
|
||
fn two_factor_independence() {
|
||
// Verifies that changing EITHER factor produces a different key
|
||
let carrier = make_test_jpeg(400, 300);
|
||
let image_secret = [0xAAu8; 32];
|
||
let salt = [0x01u8; 32];
|
||
let params = KdfParams {
|
||
argon2_m: 256,
|
||
argon2_t: 1,
|
||
argon2_p: 1,
|
||
};
|
||
|
||
let key_original = derive_master_key(b"passphrase", &image_secret, &salt, ¶ms).unwrap();
|
||
|
||
// Different passphrase, same image → different key
|
||
let key_diff_pass = derive_master_key(b"other", &image_secret, &salt, ¶ms).unwrap();
|
||
assert_ne!(key_original, key_diff_pass);
|
||
|
||
// Same passphrase, different image → different key
|
||
let key_diff_img = derive_master_key(b"passphrase", &[0xBBu8; 32], &salt, ¶ms).unwrap();
|
||
assert_ne!(key_original, key_diff_img);
|
||
|
||
// Both different → different key
|
||
let key_both = derive_master_key(b"other", &[0xBBu8; 32], &salt, ¶ms).unwrap();
|
||
assert_ne!(key_original, key_both);
|
||
assert_ne!(key_diff_pass, key_both);
|
||
assert_ne!(key_diff_img, key_both);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run integration tests**
|
||
|
||
Run: `cargo test -p idfoto-core --test integration`
|
||
Expected: Both tests PASS.
|
||
|
||
- [ ] **Step 3: Run the full test suite**
|
||
|
||
Run: `cargo test`
|
||
Expected: ALL tests across all crates PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/idfoto-core/tests/
|
||
git commit -m "test: add full-workflow integration test and two-factor independence verification"
|
||
```
|
||
|
||
---
|
||
|
||
## Plan 2 Preview
|
||
|
||
After this plan is complete and passing, Plan 2 covers:
|
||
- **idfoto-wasm**: wasm-bindgen wrapper around idfoto-core (compile with `wasm-pack build`)
|
||
- **Chrome MV3 extension**: TypeScript popup + content script + service worker, loading the WASM module for inline crypto
|
||
- **Extension UX**: passphrase prompt, entry list/search, autofill detection
|
||
|
||
Plan 2 will be written as a separate plan document once Plan 1 is fully working.
|