Files
relicario/docs/superpowers/plans/2026-04-11-idfoto-core-cli.md
2026-04-11 23:15:20 -04:00

87 KiB
Raw Blame History

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/axsbadge.me)
├── 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

# Cargo.toml
[workspace]
resolver = "2"
members = [
    "crates/idfoto-core",
    "crates/idfoto-cli",
]
  • Step 2: Create idfoto-core crate
# 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]
// crates/idfoto-core/src/lib.rs
pub mod error;
  • Step 3: Create idfoto-cli crate
# 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"
// 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
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

// 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
// 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
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

// 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
// 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
// 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
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:

    #[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:

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:

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
// 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
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

// 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
// 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
// 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
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

// 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
// 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
// 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
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
// 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
// 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
// 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
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:

    #[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:

/// 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
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:

    #[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:

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:

    #[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
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:

    #[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:

/// 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:

/// 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
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

// 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
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:

    /// Manage trusted devices
    Device {
        #[command(subcommand)]
        action: DeviceCommands,
    },

Add the subcommand enum:

#[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():

        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:

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]:

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
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
// 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
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.