Files
relicario/docs/superpowers/plans/2026-04-11-idfoto-core-cli.md
adlee-was-taken 20ff1d9f47 feat: add logo and polish icon presentation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:44:04 -04:00

2995 lines
87 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, &params).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, &params).unwrap();
let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, &params).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, &params).unwrap();
let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, &params).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, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, &params).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, &params).unwrap();
let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, &params).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, &params).unwrap();
let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, &params).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, &region, 0, 0);
write_block(&mut y_channel, &region, 0, 0, &original);
let recovered = read_block(&y_channel, &region, 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(&region, 100);
let blocks2 = select_embed_blocks(&region, 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(&region, 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, &region, 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, &region, 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(&region, 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, &region, 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, &params)
.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, &params)
.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(&params)?,
)?;
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, &params).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, &params).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, &params).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, &params).unwrap();
// Different passphrase, same image → different key
let key_diff_pass = derive_master_key(b"other", &image_secret, &salt, &params).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, &params).unwrap();
assert_ne!(key_original, key_diff_img);
// Both different → different key
let key_both = derive_master_key(b"other", &[0xBBu8; 32], &salt, &params).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.