From 847051216d9a0e3a4770d82d4e45a6c80eec5ea7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 12 Apr 2026 09:01:48 -0400 Subject: [PATCH] docs: add comprehensive doc comments to all Rust source files Document every public function, struct, field, constant, and non-trivial private function across idfoto-core and idfoto-cli. Module-level docs explain each module's role in the architecture. Comments explain the "why" (crypto choices, algorithm design, data model rationale) not just the "what". Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/idfoto-cli/src/main.rs | 207 ++++++++++++++++-- crates/idfoto-core/src/crypto.rs | 131 +++++++++++- crates/idfoto-core/src/entry.rs | 80 ++++++- crates/idfoto-core/src/error.rs | 45 ++++ crates/idfoto-core/src/imgsecret.rs | 315 ++++++++++++++++++++++++++-- crates/idfoto-core/src/lib.rs | 34 +++ crates/idfoto-core/src/vault.rs | 49 +++++ 7 files changed, 823 insertions(+), 38 deletions(-) diff --git a/crates/idfoto-cli/src/main.rs b/crates/idfoto-cli/src/main.rs index e31c623..a020c80 100644 --- a/crates/idfoto-cli/src/main.rs +++ b/crates/idfoto-cli/src/main.rs @@ -1,3 +1,42 @@ +//! idfoto CLI -- the platform layer for the idfoto password manager. +//! +//! This binary provides the filesystem, git, and terminal I/O that +//! [`idfoto_core`] intentionally excludes. It is the "glue" between the +//! platform-agnostic core library and the user's local environment. +//! +//! ## Vault layout on disk +//! +//! ```text +//! / +//! .idfoto/ +//! salt # 32-byte random salt for Argon2id KDF +//! params.json # KDF tuning parameters (m, t, p) +//! devices.json # registered device public keys +//! entries/ +//! .enc # individual encrypted entries +//! manifest.enc # encrypted entry index (name, url, username per entry) +//! .gitignore # excludes reference.jpg from version control +//! reference.jpg # the reference image with embedded secret (gitignored) +//! ``` +//! +//! ## Unlock flow +//! +//! Every command that accesses vault data follows this sequence: +//! +//! 1. Locate the reference image (via `IDFOTO_IMAGE` env var or interactive prompt). +//! 2. Prompt for the passphrase (read from stderr, not echoed). +//! 3. Extract the 32-byte image secret from the reference JPEG via DCT steganography. +//! 4. Read the vault salt and KDF params from `.idfoto/`. +//! 5. Derive the master key: `Argon2id(passphrase || image_secret, salt, params)`. +//! 6. Use the master key to decrypt the manifest and/or individual entries. +//! +//! ## Git integration +//! +//! The CLI shells out to the `git` binary for all version control operations. +//! This avoids pulling in libgit2 or gitoxide as dependencies, keeping the +//! binary small and the build simple. Every mutation (add, edit, rm, device add/revoke) +//! creates a git commit, preserving an audit log of all vault changes. + use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use idfoto_core::{ @@ -14,6 +53,7 @@ use std::process::Command; // ─── CLI structure ────────────────────────────────────────────────────────── +/// Top-level CLI argument parser. #[derive(Parser)] #[command( name = "idfoto", @@ -25,70 +65,105 @@ struct Cli { command: Commands, } +/// All available CLI subcommands. #[derive(Subcommand)] enum Commands { - /// Initialize a new idfoto vault + /// Initialize a new idfoto vault in the current directory. + /// Creates the directory structure, generates a random image secret, + /// embeds it in the carrier image, and sets up git. Init { + /// Path to the carrier JPEG image to embed the secret into. #[arg(long)] image: PathBuf, + /// Output path for the reference image (with embedded secret). #[arg(long, default_value = "reference.jpg")] output: PathBuf, }, - /// Add a new password entry + /// Add a new password entry to the vault. + /// Prompts interactively for name, URL, username, password, notes, and TOTP. Add, - /// Get a password entry by name + /// Get a password entry by name (fuzzy search). + /// Decrypts and displays the full entry, and copies the password to clipboard + /// with a 30-second auto-clear. Get { name: String }, - /// List all entries + /// List all entries in the vault (names, URLs, usernames only -- no passwords). List, - /// Edit an existing entry + /// Edit an existing entry by name (fuzzy search). + /// Shows current values and lets you selectively update fields. Edit { name: String }, - /// Remove an entry + /// Remove an entry from the vault by name (fuzzy search). + /// Prompts for confirmation before deleting. Rm { name: String }, - /// Sync vault with git remote + /// Sync the vault with the git remote (pull --rebase, then push). Sync, - /// Generate a random password + /// Generate a random password and print it to stdout. Generate { + /// Length of the generated password in characters. #[arg(short, long, default_value = "20")] length: usize, }, - /// Manage devices + /// Manage device keys (add, list, revoke). + /// Device ed25519 keys are independent of the vault KDF -- revoking a device + /// does not require changing the passphrase or reference image. Device { #[command(subcommand)] action: DeviceCommands, }, } +/// Subcommands for device key management. #[derive(Subcommand)] enum DeviceCommands { - /// Add a new device + /// Register a new device by generating an ed25519 keypair. + /// The private key is saved to the user's config directory; + /// the public key is added to the vault's devices.json. Add { + /// Human-readable name for this device (e.g., "macbook", "phone"). #[arg(long)] name: String, }, - /// List registered devices + /// List all registered devices and their public keys. List, - /// Revoke a device + /// Revoke a device by removing its public key from devices.json. + /// This does NOT rotate the vault key -- the device can no longer + /// authenticate, but the vault encryption is unchanged. Revoke { name: String }, } // ─── Device entry ─────────────────────────────────────────────────────────── +/// A registered device, stored in `.idfoto/devices.json`. +/// +/// Each device has an ed25519 keypair. The private key lives on the device +/// itself (in the user's config directory); only the public key is stored +/// in the vault. This separation means revoking a device is a metadata-only +/// operation that does not affect the vault's encryption key. #[derive(Debug, Clone, Serialize, Deserialize)] struct DeviceEntry { + /// Human-readable device name (e.g., "macbook-pro", "pixel-7"). name: String, + /// Hex-encoded ed25519 public key (64 hex chars = 32 bytes). public_key: String, // hex-encoded } // ─── Helper functions ─────────────────────────────────────────────────────── +/// Returns the vault root directory (the current working directory). +/// The vault is always rooted at the directory where `idfoto` is invoked. fn vault_dir() -> PathBuf { std::env::current_dir().expect("failed to get current directory") } +/// Returns the path to the `.idfoto/` configuration directory within the vault. fn idfoto_dir() -> PathBuf { vault_dir().join(".idfoto") } +/// Read the 32-byte vault salt from `.idfoto/salt`. +/// +/// The salt is generated once during `init` and is unique per vault. It is +/// not secret (stored in plaintext) -- its purpose is to prevent precomputed +/// rainbow table attacks against the Argon2id KDF. fn read_salt() -> Result<[u8; 32]> { let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?; let mut salt = [0u8; 32]; @@ -99,6 +174,7 @@ fn read_salt() -> Result<[u8; 32]> { Ok(salt) } +/// Read the KDF parameters from `.idfoto/params.json`. fn read_params() -> Result { let data = fs::read_to_string(idfoto_dir().join("params.json")) .context("failed to read params.json")?; @@ -106,6 +182,10 @@ fn read_params() -> Result { Ok(params) } +/// Locate the reference image path. +/// +/// First checks the `IDFOTO_IMAGE` environment variable (useful for scripting +/// and testing). If not set, prompts the user interactively. fn get_image_path() -> Result { if let Ok(path) = std::env::var("IDFOTO_IMAGE") { return Ok(PathBuf::from(path)); @@ -114,6 +194,13 @@ fn get_image_path() -> Result { Ok(PathBuf::from(path)) } +/// Perform the two-factor unlock sequence and return the derived master key. +/// +/// This is the core authentication flow used by every vault-access command: +/// 1. Prompt for the passphrase (via rpassword, not echoed to terminal). +/// 2. Read and decode the reference JPEG, extracting the steganographic secret. +/// 3. Load the vault salt and KDF params. +/// 4. Derive the master key via Argon2id(passphrase || image_secret, salt). fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> { let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?; @@ -130,18 +217,25 @@ fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> { Ok(master_key) } +/// Decrypt and return the vault manifest. fn read_manifest(key: &[u8; 32]) -> Result { let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?; let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?; Ok(manifest) } +/// Encrypt and write the vault manifest to disk. fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> { let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?; fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?; Ok(()) } +/// Stage all changes and create a git commit with the given message. +/// +/// Every vault mutation is committed to preserve a full audit log in git history. +/// The CLI shells out to the `git` binary rather than using a Rust git library +/// to keep dependencies minimal. fn git_commit(message: &str) -> Result<()> { let status = Command::new("git") .args(["add", "-A"]) @@ -162,6 +256,10 @@ fn git_commit(message: &str) -> Result<()> { Ok(()) } +/// Return the current time as a Unix timestamp string. +/// +/// Uses seconds since epoch rather than a formatted ISO 8601 string to avoid +/// pulling in chrono or time crate dependencies. fn now_iso8601() -> String { let duration = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -169,6 +267,7 @@ fn now_iso8601() -> String { format!("{}", duration.as_secs()) } +/// Prompt the user for input via stderr (so stdout remains clean for piping). fn prompt(message: &str) -> Result { eprint!("{}: ", message); io::stderr().flush()?; @@ -177,6 +276,7 @@ fn prompt(message: &str) -> Result { Ok(line.trim().to_string()) } +/// Prompt for an optional field. Returns `None` if the user enters an empty string. fn prompt_optional(message: &str) -> Result> { let value = prompt(message)?; if value.is_empty() { @@ -186,6 +286,8 @@ fn prompt_optional(message: &str) -> Result> { } } +/// Prompt for a field with a default value shown in brackets. +/// If the user presses Enter without typing, the current value is kept. fn prompt_with_default(field: &str, current: &str) -> Result { eprint!("{} [{}]: ", field, current); io::stderr().flush()?; @@ -199,6 +301,10 @@ fn prompt_with_default(field: &str, current: &str) -> Result { } } +/// Generate a random password of the given length using a mixed character set. +/// +/// The charset includes lowercase, uppercase, digits, and common symbols. +/// Each character is selected uniformly at random via the OS CSPRNG. fn generate_password(length: usize) -> String { const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; let mut rng = OsRng; @@ -212,6 +318,19 @@ fn generate_password(length: usize) -> String { // ─── Command implementations ──────────────────────────────────────────────── +/// Initialize a new idfoto vault in the current directory. +/// +/// Full sequence: +/// 1. Read the carrier JPEG provided by the user. +/// 2. Generate a random 32-byte image secret. +/// 3. Embed the secret into the carrier via DCT steganography. +/// 4. Save the resulting reference JPEG (this is the user's second factor). +/// 5. Prompt for a passphrase (minimum 8 characters, with confirmation). +/// 6. Generate a random 32-byte salt. +/// 7. Derive the master key from passphrase + image_secret + salt. +/// 8. Create the vault directory structure (.idfoto/, entries/). +/// 9. Write salt, KDF params, empty devices list, and encrypted empty manifest. +/// 10. Initialize git and create the first commit. fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { // 1. Read carrier JPEG let carrier = fs::read(&image).context("failed to read carrier image")?; @@ -274,7 +393,8 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { fs::write(vault_dir().join("manifest.enc"), manifest_enc) .context("failed to write manifest.enc")?; - // 11. Create .gitignore + // 11. Create .gitignore (exclude reference image from version control -- + // it contains the steganographic secret and must be kept offline) fs::write(vault_dir().join(".gitignore"), "reference.jpg\n") .context("failed to write .gitignore")?; @@ -292,11 +412,16 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { Ok(()) } +/// Generate a random password and print it to stdout. fn cmd_generate(length: usize) -> Result<()> { println!("{}", generate_password(length)); Ok(()) } +/// Add a new entry to the vault. +/// +/// Prompts for all fields, encrypts the entry, writes it to `entries/.enc`, +/// updates the manifest, and commits the change to git. fn cmd_add() -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; @@ -362,6 +487,10 @@ fn cmd_add() -> Result<()> { Ok(()) } +/// Search the manifest for entries matching a query and let the user select one. +/// +/// If exactly one entry matches, it is returned immediately. If multiple match, +/// the user is shown a numbered list and prompted to choose. fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> { let results = manifest.search(query); if results.is_empty() { @@ -394,6 +523,11 @@ fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, Manife Ok((id.clone(), entry.clone())) } +/// Retrieve and display a vault entry, and copy its password to the clipboard. +/// +/// The password is auto-cleared from the clipboard after 30 seconds to limit +/// exposure. The clipboard clear is best-effort (a background thread checks +/// whether the clipboard still contains the password before clearing). fn cmd_get(query: String) -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; @@ -422,7 +556,10 @@ fn cmd_get(query: String) -> Result<()> { println!("TOTP: {}", totp); } - // Copy password to clipboard with 30s TTL + // Copy password to clipboard with 30s TTL. + // Uses arboard for cross-platform clipboard access. + // The clear is done in a background thread: after 30 seconds, if the + // clipboard still contains this password, it is replaced with an empty string. match arboard::Clipboard::new() { Ok(mut clipboard) => { if clipboard.set_text(&entry.password).is_ok() { @@ -448,6 +585,10 @@ fn cmd_get(query: String) -> Result<()> { Ok(()) } +/// List all vault entries in alphabetical order. +/// +/// Only shows non-sensitive metadata (name, URL, username) from the manifest. +/// Individual entry files are not decrypted. fn cmd_list() -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; @@ -477,6 +618,8 @@ fn cmd_list() -> Result<()> { Ok(()) } +/// Edit an existing entry by searching for it, showing current values, and +/// prompting for new values. Unchanged fields keep their current value. fn cmd_edit(query: String) -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; @@ -546,6 +689,10 @@ fn cmd_edit(query: String) -> Result<()> { Ok(()) } +/// Remove an entry from the vault after confirmation. +/// +/// Deletes the encrypted entry file, removes the entry from the manifest, +/// and commits the change to git. fn cmd_rm(query: String) -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; @@ -576,6 +723,11 @@ fn cmd_rm(query: String) -> Result<()> { Ok(()) } +/// Sync the vault with the git remote. +/// +/// Performs `git pull --rebase` followed by `git push`. Rebase is used instead +/// of merge to keep the commit history linear, which is important for the +/// audit log use case. fn cmd_sync() -> Result<()> { eprintln!("Pulling..."); let status = Command::new("git") @@ -601,6 +753,7 @@ fn cmd_sync() -> Result<()> { // ─── Device management ────────────────────────────────────────────────────── +/// Read the device registry from `.idfoto/devices.json`. fn read_devices() -> Result> { let path = idfoto_dir().join("devices.json"); let data = fs::read_to_string(&path).context("failed to read devices.json")?; @@ -608,30 +761,39 @@ fn read_devices() -> Result> { Ok(devices) } +/// Write the device registry to `.idfoto/devices.json`. fn write_devices(devices: &[DeviceEntry]) -> Result<()> { let data = serde_json::to_string_pretty(devices)?; fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?; Ok(()) } +/// Register a new device by generating an ed25519 keypair. +/// +/// The private key is saved to `~/.config/idfoto/.key` with +/// restrictive permissions (0600 on Unix). The public key is added to +/// the vault's devices.json and committed to git. +/// +/// Device keys are independent of the vault encryption key -- revoking a +/// device does not require rotating the passphrase or reference image. fn cmd_device_add(name: String) -> Result<()> { use ed25519_dalek::SigningKey; let mut devices = read_devices()?; - // Check for duplicate + // Check for duplicate device names if devices.iter().any(|d| d.name == name) { bail!("device '{}' already exists", name); } - // Generate ed25519 keypair + // Generate ed25519 keypair using the OS CSPRNG let signing_key = SigningKey::generate(&mut OsRng); let verifying_key = signing_key.verifying_key(); let private_key_hex = hex::encode(signing_key.to_bytes()); let public_key_hex = hex::encode(verifying_key.to_bytes()); - // Save private key + // Save private key to the user's config directory (NOT in the vault) let config_dir = dirs::config_dir() .context("failed to find config directory")? .join("idfoto"); @@ -646,7 +808,7 @@ fn cmd_device_add(name: String) -> Result<()> { fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; } - // Add to devices.json + // Add public key to the vault's device registry devices.push(DeviceEntry { name: name.clone(), public_key: public_key_hex, @@ -660,6 +822,7 @@ fn cmd_device_add(name: String) -> Result<()> { Ok(()) } +/// List all registered devices with their public keys. fn cmd_device_list() -> Result<()> { let devices = read_devices()?; @@ -677,6 +840,13 @@ fn cmd_device_list() -> Result<()> { Ok(()) } +/// Revoke a device by removing it from the device registry. +/// +/// This is a metadata-only operation: the device's public key is removed from +/// devices.json, but the vault encryption key is NOT rotated. The revoked +/// device can no longer authenticate via its ed25519 key, but if it had +/// previously derived the master key (via passphrase + image), that key +/// remains valid until the user changes their passphrase or reference image. fn cmd_device_revoke(name: String) -> Result<()> { let mut devices = read_devices()?; let initial_len = devices.len(); @@ -695,6 +865,7 @@ fn cmd_device_revoke(name: String) -> Result<()> { // ─── Main ─────────────────────────────────────────────────────────────────── +/// Entry point: parse CLI arguments and dispatch to the appropriate command handler. fn main() -> Result<()> { let cli = Cli::parse(); diff --git a/crates/idfoto-core/src/crypto.rs b/crates/idfoto-core/src/crypto.rs index 7d35f52..319beb3 100644 --- a/crates/idfoto-core/src/crypto.rs +++ b/crates/idfoto-core/src/crypto.rs @@ -1,3 +1,48 @@ +//! Argon2id key derivation and XChaCha20-Poly1305 authenticated encryption. +//! +//! This module implements the low-level "encrypt bytes / decrypt bytes" layer. +//! Higher-level typed wrappers (encrypt_entry, encrypt_manifest) live in [`crate::vault`]. +//! +//! ## Why XChaCha20-Poly1305 over AES-GCM +//! +//! - **192-bit nonce** (vs. 96-bit for AES-GCM): eliminates nonce collision risk +//! even with random nonces across billions of encryptions. With AES-GCM's 96-bit +//! nonce, birthday-bound collisions become probable around 2^48 messages under +//! the same key -- a real concern for a long-lived vault. +//! - **Fast on WASM and ARM without AES-NI**: ChaCha20 is a pure arithmetic cipher +//! (add/rotate/XOR) with no dependency on hardware AES acceleration. AES-GCM is +//! fast *only* with AES-NI; without it, software AES is both slow and vulnerable +//! to cache-timing side channels. +//! +//! ## Binary ciphertext format +//! +//! Every encrypted blob produced by [`encrypt`] has this layout: +//! +//! ```text +//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable] +//! ``` +//! +//! - **Version byte** (`0x01`): allows future format changes without ambiguity. +//! Decryption rejects any version it does not recognize. +//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`]. +//! Stored alongside the ciphertext so the decryptor does not need out-of-band +//! nonce management. +//! - **Ciphertext + tag**: the AEAD output. The Poly1305 tag (16 bytes) is +//! appended by the cipher implementation; we do not separate it. +//! +//! ## KDF pipeline +//! +//! [`derive_master_key`] concatenates the passphrase and image_secret as a single +//! password input to Argon2id: +//! +//! ```text +//! password = passphrase_bytes || image_secret (32 bytes) +//! master_key = Argon2id(password, salt, params) -> 32 bytes +//! ``` +//! +//! Both factors contribute to the derived key -- compromising one without the +//! other is insufficient. The salt is vault-specific and stored in `.idfoto/salt`. + use argon2::{Algorithm, Argon2, Params, Version}; use chacha20poly1305::{ aead::{Aead, KeyInit}, @@ -8,14 +53,35 @@ use serde::{Deserialize, Serialize}; use crate::error::{IdfotoError, Result}; +/// Current binary format version. Increment this if the ciphertext layout changes. const VERSION_BYTE: u8 = 0x01; + +/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes. const NONCE_LEN: usize = 24; + +/// Poly1305 authentication tag length: 128 bits = 16 bytes. +/// Used only for minimum-length validation during decryption. const TAG_LEN: usize = 16; + +/// Total header size: version byte + nonce. The ciphertext (including tag) +/// follows immediately after the header. const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce +/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305. +/// +/// Returns the binary blob in the format: `version(1) || nonce(24) || ciphertext+tag`. +/// A fresh random nonce is generated for each call via the OS CSPRNG. +/// +/// # Errors +/// +/// Returns [`IdfotoError::Encrypt`] if the underlying AEAD operation fails +/// (extremely unlikely in practice). pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { let cipher = XChaCha20Poly1305::new(key.into()); + // Generate a fresh random 24-byte nonce for every encryption. + // With 192 bits of randomness, nonce reuse probability is negligible + // even across billions of encryptions under the same key. let mut nonce_bytes = [0u8; NONCE_LEN]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = XNonce::from(nonce_bytes); @@ -33,7 +99,22 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { Ok(output) } +/// Decrypt a blob produced by [`encrypt`], returning the original plaintext. +/// +/// Validates the version byte and minimum blob length before attempting +/// authenticated decryption. If the key is wrong or the data has been +/// tampered with, the Poly1305 tag verification fails and [`IdfotoError::Decrypt`] +/// is returned -- with no information about which bytes were wrong (preventing +/// padding oracle / chosen-ciphertext attacks). +/// +/// # Errors +/// +/// - [`IdfotoError::Format`] if the data is too short or has an unknown version byte. +/// - [`IdfotoError::Decrypt`] if the AEAD tag verification fails (wrong key or +/// tampered data). pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result> { + // Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes. + // A zero-length plaintext produces exactly 41 bytes of output. if data.len() < HEADER_LEN + TAG_LEN { return Err(IdfotoError::Format( "data too short to be valid ciphertext".into(), @@ -59,13 +140,36 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result> { Ok(plaintext) } +/// Tunable parameters for the Argon2id key derivation function. +/// +/// These are stored in the vault's `.idfoto/params.json` so that every client +/// derives the same master key from the same inputs. Making them configurable +/// lets tests use fast params (m=256, t=1, p=1) while production uses strong +/// params (m=64MiB, t=3, p=4). +/// +/// The parameters follow Argon2id naming conventions: +/// - `argon2_m`: memory cost in KiB +/// - `argon2_t`: time cost (number of iterations) +/// - `argon2_p`: parallelism degree (number of lanes) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KdfParams { + /// Memory cost in KiB. Default is 65536 (64 MiB), which makes GPU/ASIC + /// brute-force attacks expensive. Tests use 256 KiB for speed. pub argon2_m: u32, + /// Time cost (iteration count). Default is 3. Higher values increase CPU + /// time linearly. Combined with high memory cost, this makes each key + /// derivation take ~1 second on modern hardware. pub argon2_t: u32, + /// Parallelism degree. Default is 4. Sets the number of independent lanes + /// in the Argon2id memory-hard computation. pub argon2_p: u32, } +/// Production-strength default parameters: 64 MiB memory, 3 iterations, 4 lanes. +/// +/// These are calibrated to take roughly 0.5-1 second on a modern desktop CPU, +/// making brute-force attacks impractical while keeping interactive unlock fast +/// enough for daily use. impl Default for KdfParams { fn default() -> Self { Self { @@ -76,6 +180,28 @@ impl Default for KdfParams { } } +/// Derive a 256-bit master key from the user's passphrase and reference image secret. +/// +/// The two factors (passphrase + image_secret) are concatenated into a single +/// password input to Argon2id. This means both factors contribute entropy to +/// the derived key -- compromising one factor alone is insufficient. +/// +/// # Arguments +/// +/// - `passphrase`: the user's passphrase as raw UTF-8 bytes. +/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via +/// [`crate::imgsecret::extract`]. +/// - `salt`: a 32-byte vault-specific salt (stored in `.idfoto/salt`). +/// - `params`: the Argon2id tuning parameters (stored in `.idfoto/params.json`). +/// +/// # Returns +/// +/// A 32-byte master key suitable for use with [`encrypt`] and [`decrypt`]. +/// +/// # Errors +/// +/// Returns [`IdfotoError::Kdf`] if the Argon2id parameters are invalid (e.g., +/// memory cost below the library's minimum). pub fn derive_master_key( passphrase: &[u8], image_secret: &[u8; 32], @@ -92,7 +218,10 @@ pub fn derive_master_key( let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); - // Concatenate passphrase + image_secret as the password input + // Concatenate passphrase + image_secret as the password input. + // This ensures both factors contribute to the derived key: knowing only + // the passphrase (without the reference image) or only the image secret + // (without the passphrase) is insufficient to derive the correct master key. let mut password = Vec::with_capacity(passphrase.len() + 32); password.extend_from_slice(passphrase); password.extend_from_slice(image_secret); diff --git a/crates/idfoto-core/src/entry.rs b/crates/idfoto-core/src/entry.rs index db0d36a..1670305 100644 --- a/crates/idfoto-core/src/entry.rs +++ b/crates/idfoto-core/src/entry.rs @@ -1,8 +1,48 @@ +//! Vault data model: entries, manifest entries, and the manifest index. +//! +//! The vault stores credentials in two tiers: +//! +//! 1. **Individual entries** (`entries/.enc`): each file contains a single +//! [`Entry`] encrypted with the master key. Only decrypted when the user +//! needs to read or edit a specific credential. +//! +//! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a +//! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries. +//! This lets the CLI list and search entries by decrypting only one file, +//! rather than decrypting every entry in the vault. +//! +//! ## Entry IDs +//! +//! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy, +//! ~4 billion possible values). This is sufficient for family-scale vaults while +//! keeping filenames short and filesystem-friendly. +//! +//! ## Serialization strategy +//! +//! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields +//! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact +//! -- omitting null fields reduces ciphertext size and avoids leaking structural +//! information about which optional fields a credential uses. + use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -/// A single password entry (stored encrypted in entries/.enc). +/// A full credential entry stored encrypted in `entries/.enc`. +/// +/// Contains all sensitive data for a single credential. Each entry is encrypted +/// independently, so accessing one entry does not require decrypting others. +/// +/// ## Fields +/// +/// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required. +/// - `url`: the login URL. Optional; used for autofill matching in the browser extension. +/// - `username`: the account username or email. Optional. +/// - `password`: the credential password. Required (this is the core secret). +/// - `notes`: free-form text (e.g., security questions, recovery codes). Optional. +/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional. +/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created. +/// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { pub name: String, @@ -19,25 +59,46 @@ pub struct Entry { pub updated_at: String, } -/// Summary info about an entry (stored in the manifest). +/// Summary metadata for a single entry, stored in the manifest. +/// +/// This is a lightweight projection of [`Entry`] that contains only the +/// non-sensitive fields needed for listing and searching. The password, +/// notes, and TOTP secret are intentionally excluded so that listing +/// entries requires decrypting only the manifest, not every individual entry. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestEntry { + /// Human-readable label for display and search matching. pub name: String, + /// Login URL for search matching and browser extension autofill. #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, + /// Account username for display in entry listings. #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, + /// Timestamp of last modification, used for sorting and display. pub updated_at: String, } -/// The vault manifest — maps entry IDs to their metadata. +/// The vault manifest -- an encrypted index mapping entry IDs to their metadata. +/// +/// The manifest serves two purposes: +/// +/// 1. **Efficient listing**: decrypting the single manifest file is enough to show +/// all entry names, URLs, and usernames without touching individual entry files. +/// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive +/// substring matching against entry names and URLs. +/// +/// The `version` field allows future schema migrations if the manifest format evolves. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { + /// Map from entry ID (8-char hex string) to entry metadata. pub entries: HashMap, + /// Schema version. Currently always `1`. pub version: u32, } impl Manifest { + /// Create a new empty manifest with version 1. pub fn new() -> Self { Manifest { entries: HashMap::new(), @@ -45,14 +106,23 @@ impl Manifest { } } + /// Insert or update an entry in the manifest. + /// + /// If an entry with the same ID already exists, it is overwritten. + /// This is used both for `add` (new entry) and `edit` (update existing). pub fn add_entry(&mut self, id: String, entry: ManifestEntry) { self.entries.insert(id, entry); } + /// Remove an entry from the manifest by ID, returning its metadata if it existed. pub fn remove_entry(&mut self, id: &str) -> Option { self.entries.remove(id) } + /// Search entries by case-insensitive substring match against name and URL. + /// + /// Returns a vector of `(id, entry)` pairs for all matching entries. An entry + /// matches if the query appears in its name or URL (case-insensitive). pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> { let q = query.to_lowercase(); self.entries @@ -75,6 +145,10 @@ impl Default for Manifest { } /// Generate a random 8-character hex string to use as an entry ID. +/// +/// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`. +/// This gives ~4 billion possible values, which is more than sufficient for +/// a family-scale vault (typically < 1000 entries). pub fn generate_entry_id() -> String { let mut rng = rand::thread_rng(); let bytes: [u8; 4] = rng.gen(); diff --git a/crates/idfoto-core/src/error.rs b/crates/idfoto-core/src/error.rs index c509fa3..194c5f5 100644 --- a/crates/idfoto-core/src/error.rs +++ b/crates/idfoto-core/src/error.rs @@ -1,25 +1,59 @@ +//! Unified error type for the idfoto-core crate. +//! +//! Every fallible function in this crate returns [`Result`], which is an alias +//! for `std::result::Result`. Using a single error enum keeps the +//! public API surface predictable and makes error handling in callers (CLI, WASM +//! bindings, mobile FFI) straightforward. + use thiserror::Error; +/// All errors that can originate from idfoto-core operations. +/// +/// Variants are ordered roughly by the pipeline stage where they occur: +/// KDF -> encryption -> decryption -> format parsing -> entry lookup -> image +/// steganography -> serialization -> device keys. #[derive(Debug, Error)] pub enum IdfotoError { + /// The Argon2id key derivation failed. This typically means invalid KDF + /// parameters were supplied (e.g., memory cost below Argon2's minimum). #[error("key derivation failed: {0}")] Kdf(String), + /// XChaCha20-Poly1305 encryption failed. In practice this is extremely rare + /// -- the only realistic cause is an internal library error, since the cipher + /// accepts arbitrary-length plaintext. #[error("encryption failed: {0}")] Encrypt(String), + /// Authenticated decryption failed. This means either the wrong master key + /// was used (wrong passphrase or wrong reference image) or the ciphertext + /// was tampered with / corrupted in transit or at rest. The error message is + /// intentionally vague to avoid leaking information about which factor was + /// wrong (passphrase vs. image). #[error("decryption failed: wrong key or corrupted data")] Decrypt, + /// The binary ciphertext blob does not match the expected format (e.g., + /// too short to contain the version byte + nonce + tag, or an unrecognized + /// version byte). This usually indicates file corruption or a version + /// mismatch between the writer and reader. #[error("invalid vault format: {0}")] Format(String), + /// A vault entry was looked up by ID but does not exist in the manifest. + /// The string payload is the missing entry ID. #[error("entry not found: {0}")] EntryNotFound(String), + /// A general error from the image steganography subsystem (imgsecret). + /// Covers issues like failing to decode the carrier JPEG or failing to + /// encode the output JPEG after modification. #[error("imgsecret: {0}")] ImgSecret(String), + /// The carrier image is too small to hold the embedded secret with + /// sufficient redundancy. The embed region (central 70% of the image) + /// must contain at least `BLOCKS_PER_COPY * MIN_COPIES` 8x8 blocks. #[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")] ImageTooSmall { min_width: u32, @@ -28,14 +62,25 @@ pub enum IdfotoError { actual_height: u32, }, + /// Secret extraction from a JPEG failed. This can mean: + /// - The image never had a secret embedded in it. + /// - The image was recompressed below Q85, destroying the QIM watermarks. + /// - The image was cropped beyond the 15% crumple zone. + /// - Majority-vote confidence fell below the 60% threshold on one or more bits. #[error("extraction failed: no valid secret found in image")] ExtractionFailed, + /// JSON serialization or deserialization of an entry or manifest failed. + /// Wraps [`serde_json::Error`] transparently via `#[from]`. #[error("json error: {0}")] Json(#[from] serde_json::Error), + /// An error related to device ed25519 key operations. Device keys are + /// separate from the vault KDF -- revoking a device does not require + /// rotating the passphrase or reference image. #[error("device key error: {0}")] DeviceKey(String), } +/// Crate-wide result alias, reducing boilerplate in function signatures. pub type Result = std::result::Result; diff --git a/crates/idfoto-core/src/imgsecret.rs b/crates/idfoto-core/src/imgsecret.rs index ae3d94a..e27d975 100644 --- a/crates/idfoto-core/src/imgsecret.rs +++ b/crates/idfoto-core/src/imgsecret.rs @@ -1,7 +1,43 @@ -//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping. +//! DCT-based steganographic embedding of a 256-bit secret in JPEG images. //! -//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance -//! channel using Quantization Index Modulation (QIM) with majority voting. +//! This is the novel component of idfoto. It hides a 32-byte secret inside a +//! JPEG image's luminance channel using Quantization Index Modulation (QIM) on +//! mid-frequency DCT coefficients, with majority voting across multiple redundant +//! copies for robustness. +//! +//! ## High-level algorithm +//! +//! ### Embedding (`embed`) +//! +//! 1. Decode the carrier JPEG and extract the luminance (Y) channel. +//! 2. Compute the "embed region" -- the central 70% of the image (15% margin +//! on each side acts as a crumple zone for mild cropping). +//! 3. Divide the embed region into 8x8 pixel blocks and select evenly-spaced +//! blocks for embedding. +//! 4. For each copy of the secret (5-50 copies depending on image size): +//! - For each of the 22 blocks needed to hold 256 bits (12 bits per block): +//! - Apply the 2D DCT to the 8x8 block. +//! - Embed bits into 12 mid-frequency DCT coefficients using QIM. +//! - Apply the inverse DCT to write the modified block back. +//! 5. Reconstruct the JPEG by replacing only the Y channel and re-encoding. +//! +//! ### Extraction (`extract`) +//! +//! 1. Decode the JPEG and extract the Y channel. +//! 2. Try the canonical extraction (assuming the image is uncropped). +//! 3. If that fails, try crop-recovery: search for plausible original dimensions +//! and pixel offsets, reconstructing the block grid accordingly. +//! 4. For each copy of the secret, extract bits from DCT coefficients via QIM. +//! 5. Majority-vote each bit position across all copies. Require >= 60% confidence. +//! +//! ## Robustness +//! +//! The combination of QIM with a high quantization step (50.0), mid-frequency +//! coefficient placement, and majority voting across many copies makes the +//! watermark survive: +//! - JPEG recompression down to quality ~85 +//! - Mild cropping (up to ~10% from edges, within the 15% crumple zone) +//! - Color space conversions (embedding is in luminance only) use crate::error::{IdfotoError, Result}; use image::codecs::jpeg::JpegEncoder; @@ -12,15 +48,55 @@ use std::io::Cursor; // ─── Constants ─────────────────────────────────────────────────────────────── +/// DCT block size. JPEG uses 8x8 blocks, so we match that to minimize +/// interference with the JPEG codec's own quantization. const BLOCK_SIZE: usize = 8; + +/// QIM quantization step. Higher values make the watermark more robust to +/// recompression but introduce more visible artifacts. A value of 50.0 is +/// higher than the typical academic value of 25 -- this is intentional because +/// we need to survive JPEG recompression at Q85 and below, which applies +/// aggressive quantization to mid-frequency coefficients. The trade-off is +/// acceptable because the reference image is a personal photo, not a +/// publication-quality image. const QUANT_STEP: f64 = 50.0; + +/// Minimum image dimension (width or height) in pixels. Images smaller than +/// this cannot hold enough 8x8 blocks for reliable embedding. const MIN_DIMENSION: u32 = 100; + +/// Number of secret bits to embed: 256 bits = 32 bytes. const SECRET_BITS: usize = 256; + +/// Minimum number of redundant copies of the secret. More copies improve +/// extraction reliability via majority voting, but require more blocks. const MIN_COPIES: usize = 5; + +/// Number of mid-frequency DCT positions used per block. Each block carries +/// 12 bits of the secret. This matches `EMBED_POSITIONS.len()`. const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len() + +/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret. +/// ceil(256 / 12) = 22 blocks per copy. const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22 -/// Mid-frequency DCT positions (zig-zag positions 4–15) +/// Mid-frequency DCT coefficient positions for embedding, specified as +/// (row, col) indices into the 8x8 DCT coefficient matrix. +/// +/// These correspond to zig-zag scan positions 4 through 15 -- the "sweet spot" +/// between low-frequency coefficients (which carry visible image structure and +/// are heavily quantized by JPEG) and high-frequency coefficients (which carry +/// noise/detail and are aggressively zeroed by JPEG compression). +/// +/// Mid-frequency coefficients survive JPEG recompression better than high-frequency +/// ones, while causing less visible distortion than modifying low-frequency ones. +/// +/// The zig-zag ordering is the standard JPEG scan order: +/// ```text +/// Zig-zag positions 4-7: (0,3) (1,2) (2,1) (3,0) +/// Zig-zag positions 8-11: (0,4) (1,3) (2,2) (3,1) +/// Zig-zag positions 12-15: (4,0) (0,5) (1,4) (2,3) +/// ``` const EMBED_POSITIONS: [(usize, usize); 12] = [ (0, 3), (1, 2), @@ -38,17 +114,31 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [ // ─── YChannel ──────────────────────────────────────────────────────────────── +/// The luminance (Y) channel of an image, stored as a flat array of f64 values. +/// +/// We embed exclusively in the luminance channel because: +/// - Human vision is more sensitive to luminance than chrominance, so the +/// luminance channel has more "room" for watermarking in the frequency domain. +/// - JPEG compresses chrominance channels more aggressively (typically 4:2:0 +/// subsampling), which would destroy embedded data. +/// - Working with a single channel keeps the DCT operations simple and fast. struct YChannel { + /// Row-major luminance values. `data[y * width + x]` gives the luminance + /// at pixel (x, y). Values are in the range [0, 255] after extraction + /// from RGB, but may temporarily go slightly outside this range during + /// DCT manipulation. data: Vec, width: usize, height: usize, } impl YChannel { + /// Get the luminance value at pixel (x, y). fn get(&self, x: usize, y: usize) -> f64 { self.data[y * self.width + x] } + /// Set the luminance value at pixel (x, y). fn set(&mut self, x: usize, y: usize, val: f64) { self.data[y * self.width + x] = val; } @@ -56,19 +146,36 @@ impl YChannel { // ─── EmbedRegion ───────────────────────────────────────────────────────────── +/// Defines the central region of the image where embedding occurs. +/// +/// The embed region is the central 70% of the image -- a 15% margin is excluded +/// on each side. This margin acts as a "crumple zone": if the image is mildly +/// cropped (e.g., a social media platform trims edges), the embedded data in the +/// center remains intact. The 15% margin is sufficient to tolerate up to ~10% +/// cropping from any single edge. struct EmbedRegion { + /// Pixel offset from the left edge to the start of the embed region. x_offset: usize, + /// Pixel offset from the top edge to the start of the embed region. y_offset: usize, + /// Width of the embed region in pixels. #[allow(dead_code)] region_width: usize, + /// Height of the embed region in pixels. #[allow(dead_code)] region_height: usize, + /// Number of complete 8x8 blocks that fit horizontally in the embed region. blocks_x: usize, + /// Number of complete 8x8 blocks that fit vertically in the embed region. blocks_y: usize, } // ─── Helper functions ──────────────────────────────────────────────────────── +/// Decode a JPEG from raw bytes and extract the luminance (Y) channel. +/// +/// Converts each RGB pixel to luminance using the ITU-R BT.601 formula: +/// `Y = 0.299*R + 0.587*G + 0.114*B` fn extract_y_channel(jpeg_bytes: &[u8]) -> Result { let reader = ImageReader::new(Cursor::new(jpeg_bytes)) .with_guessed_format() @@ -82,6 +189,7 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result { for y in 0..height { for x in 0..width { let p = rgb.get_pixel(x as u32, y as u32); + // ITU-R BT.601 luma coefficients let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64; data.push(luma); } @@ -93,10 +201,15 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result { }) } +/// Compute the embed region for a YChannel (convenience wrapper). fn central_region(y: &YChannel) -> EmbedRegion { compute_region(y.width, y.height) } +/// Compute the central embed region for given image dimensions. +/// +/// The region excludes a 15% margin on each side, leaving the central 70%. +/// The margin acts as a crumple zone for crop tolerance. fn compute_region(width: usize, height: usize) -> EmbedRegion { let margin_x = (width as f64 * 0.15) as usize; let margin_y = (height as f64 * 0.15) as usize; @@ -116,6 +229,11 @@ fn compute_region(width: usize, height: usize) -> EmbedRegion { } } +/// Read an 8x8 pixel block from the Y channel at absolute pixel coordinates. +/// +/// Returns `None` if the block would extend beyond the image boundaries +/// (used during crop-recovery extraction where some blocks may have been +/// cropped away). fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> { if px + 8 > y.width || py + 8 > y.height { return None; @@ -129,12 +247,16 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> { Some(block) } +/// Read an 8x8 block from the Y channel using block coordinates relative to +/// the embed region. fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] { let start_x = region.x_offset + bx * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE; read_block_abs(y, start_x, start_y).unwrap() } +/// Write an 8x8 block back to the Y channel using block coordinates relative +/// to the embed region. fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) { let start_x = region.x_offset + bx * BLOCK_SIZE; let start_y = region.y_offset + by * BLOCK_SIZE; @@ -146,7 +268,22 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo } // ─── DCT ───────────────────────────────────────────────────────────────────── +// +// The Discrete Cosine Transform (DCT) converts a spatial-domain signal (pixel +// values) into a frequency-domain representation (coefficients). JPEG compression +// itself uses the 8x8 Type-II DCT, so working in the same domain lets us embed +// data where JPEG's own quantization is least destructive. +// +// We implement the DCT from scratch (rather than depending on a library) to keep +// the crate dependency-light and WASM-friendly. The 8x8 size is small enough +// that the naive O(N^2) computation is fast. +/// 1D Type-II DCT of an 8-element signal. +/// +/// Applies the orthonormal DCT-II: +/// X[k] = c(k) * sum_{i=0}^{7} x[i] * cos((2i+1)*k*pi/16) +/// +/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0. fn dct1d(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; for k in 0..8 { @@ -164,6 +301,10 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] { output } +/// 1D Type-III DCT (inverse DCT) of an 8-element signal. +/// +/// Reconstructs the spatial-domain signal from DCT coefficients: +/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16) fn idct1d(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; for i in 0..8 { @@ -181,11 +322,18 @@ fn idct1d(input: &[f64; 8]) -> [f64; 8] { output } +/// 2D DCT of an 8x8 block, computed as separable 1D DCTs. +/// +/// First applies the 1D DCT to each row, then to each column of the result. +/// This is mathematically equivalent to the full 2D DCT but faster (O(N^3) +/// instead of O(N^4) for the naive 2D formulation). fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { + // Step 1: DCT along rows let mut temp = [[0.0f64; 8]; 8]; for row in 0..8 { temp[row] = dct1d(&block[row]); } + // Step 2: DCT along columns let mut result = [[0.0f64; 8]; 8]; for col in 0..8 { let mut column = [0.0f64; 8]; @@ -200,7 +348,12 @@ fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { result } +/// 2D inverse DCT of an 8x8 block, computed as separable 1D inverse DCTs. +/// +/// Reverses the 2D DCT: first applies IDCT along columns, then along rows. +/// (The order is reversed compared to the forward transform.) fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { + // Step 1: IDCT along columns let mut temp = [[0.0f64; 8]; 8]; for col in 0..8 { let mut column = [0.0f64; 8]; @@ -212,6 +365,7 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { temp[row][col] = transformed[row]; } } + // Step 2: IDCT along rows let mut result = [[0.0f64; 8]; 8]; for row in 0..8 { result[row] = idct1d(&temp[row]); @@ -220,7 +374,28 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] { } // ─── QIM ───────────────────────────────────────────────────────────────────── +// +// Quantization Index Modulation (QIM) is the core technique for encoding bits +// into DCT coefficients. It works by quantizing each coefficient to one of two +// interleaved grids, where the grid selection encodes the bit value. +// +// For bit 0: quantize to the nearest multiple of Q (grid: ..., -Q, 0, Q, 2Q, ...) +// For bit 1: quantize to the nearest multiple of Q, offset by Q/2 (grid: ..., -Q/2, Q/2, 3Q/2, ...) +// +// Extraction simply measures which grid the coefficient is closest to. +// +// QIM is preferred over spread-spectrum or LSB methods because it is: +// - Robust to recompression (the quantization step is larger than JPEG's own) +// - Simple to implement and analyze +// - Deterministic (no pseudo-random spreading sequence to synchronize) +/// Embed a single bit into a DCT coefficient using QIM. +/// +/// Quantizes the coefficient to the nearest point on the grid selected by `bit`: +/// - `bit=0`: grid at multiples of `q` (i.e., 0, q, 2q, ...) +/// - `bit=1`: grid at multiples of `q` offset by `q/2` (i.e., q/2, 3q/2, ...) +/// +/// The returned value is the modified coefficient. fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 { let offset = if bit == 1 { q / 2.0 } else { 0.0 }; let shifted = coef - offset; @@ -228,8 +403,15 @@ fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 { quantized + offset } +/// Extract a single bit from a DCT coefficient using QIM. +/// +/// Computes the distance from the coefficient to each grid (bit-0 grid and +/// bit-1 grid) and returns whichever grid is closer. This is the ML (maximum +/// likelihood) decoder for QIM under additive noise. fn qim_extract(coef: f64, q: f64) -> u8 { + // Distance to the nearest bit-0 grid point let d0 = (coef - (coef / q).round() * q).abs(); + // Distance to the nearest bit-1 grid point (offset by q/2) let offset = q / 2.0; let shifted = coef - offset; let d1 = (shifted - (shifted / q).round() * q).abs(); @@ -238,6 +420,10 @@ fn qim_extract(coef: f64, q: f64) -> u8 { // ─── Bit conversion ────────────────────────────────────────────────────────── +/// Convert a byte slice to a vector of individual bits (MSB first). +/// +/// Each byte is expanded to 8 bits, with bit 7 (MSB) first. +/// Example: `[0xCA]` -> `[1, 1, 0, 0, 1, 0, 1, 0]` fn bytes_to_bits(bytes: &[u8]) -> Vec { let mut bits = Vec::with_capacity(bytes.len() * 8); for &byte in bytes { @@ -248,6 +434,9 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec { bits } +/// Convert a vector of individual bits (MSB first) back to bytes. +/// +/// Pads the last byte with zeros if the bit count is not a multiple of 8. fn bits_to_bytes(bits: &[u8]) -> Vec { let mut bytes = Vec::with_capacity((bits.len() + 7) / 8); for chunk in bits.chunks(8) { @@ -263,7 +452,18 @@ fn bits_to_bytes(bits: &[u8]) -> Vec { // ─── Block selection ───────────────────────────────────────────────────────── /// Compute the absolute pixel positions of embed blocks for a given image size. -/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks. +/// +/// This function deterministically maps image dimensions to a list of block +/// positions. Both the embedder and extractor call this function with the same +/// dimensions to agree on where blocks are. During crop recovery, the extractor +/// tries different assumed original dimensions to find the correct grid. +/// +/// Returns `Vec<(px, py)>` -- top-left corners of 8x8 blocks in pixel coordinates. +/// Returns an empty vec if the image is too small to embed. +/// +/// Blocks are selected with even spacing (stride) across the embed region to +/// spread the watermark uniformly, making it more resilient to localized damage. +/// The number of copies is capped at 50 to avoid diminishing returns. fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> { let region = compute_region(img_width, img_height); let total_blocks = region.blocks_x * region.blocks_y; @@ -273,6 +473,7 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); let target_count = num_copies * BLOCKS_PER_COPY; + // Stride ensures blocks are evenly distributed across the embed region let stride = (total_blocks / target_count).max(1); let mut positions = Vec::with_capacity(target_count); let mut idx = 0; @@ -287,11 +488,17 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u positions } +/// Select embed blocks using block-coordinate indices relative to the embed region. +/// +/// Similar to [`compute_embed_positions`] but returns `(bx, by)` block indices +/// rather than absolute pixel positions. Used during embedding where block +/// coordinates are more convenient for the read_block/write_block API. fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> { let total_blocks = region.blocks_x * region.blocks_y; if total_blocks == 0 || target_count == 0 { return Vec::new(); } + // Even stride distributes blocks uniformly across the region let stride = (total_blocks / target_count).max(1); let mut blocks = Vec::with_capacity(target_count); let mut idx = 0; @@ -306,6 +513,17 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, // ─── Reconstruct JPEG ──────────────────────────────────────────────────────── +/// Reconstruct a JPEG image after modifying its luminance channel. +/// +/// This function takes the original JPEG (for its Cb/Cr chrominance data) and +/// the modified Y channel, then: +/// +/// 1. Decodes the original JPEG to get per-pixel Cb and Cr values. +/// 2. For each pixel, combines the modified Y with the original Cb/Cr. +/// 3. Converts YCbCr back to RGB using the ITU-R BT.601 inverse formula. +/// 4. Re-encodes as JPEG at quality 92 (high enough to preserve the watermark). +/// +/// Only the luminance changes; chrominance is preserved from the original. fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result> { let reader = ImageReader::new(Cursor::new(original_jpeg)) .with_guessed_format() @@ -325,12 +543,15 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result RGB using ITU-R BT.601 inverse let r_new = y_new + 1.402 * (cr - 128.0); let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0); let b_new = y_new + 1.772 * (cb - 128.0); @@ -357,7 +578,28 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result