# Relicario 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: `relicario-core` (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and `relicario-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 `relicario-core` and `relicario-cli`. Plan 2 (relicario-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-relicario-design.md` --- ## File Structure ``` relicario/ (project root = /home/alee/Sources/relicario) ├── Cargo.toml # workspace root ├── crates/ │ ├── relicario-core/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs # re-exports public API │ │ ├── error.rs # RelicarioError 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 │ └── relicario-cli/ │ ├── Cargo.toml │ └── src/ │ └── main.rs # clap CLI with all subcommands ├── docs/ │ └── superpowers/ │ ├── specs/ │ │ └── 2026-04-11-relicario-design.md │ └── plans/ │ └── 2026-04-11-relicario-core-cli.md (this file) └── README.md ``` --- ### Task 1: Workspace Scaffolding **Files:** - Create: `Cargo.toml` - Create: `crates/relicario-core/Cargo.toml` - Create: `crates/relicario-core/src/lib.rs` - Create: `crates/relicario-cli/Cargo.toml` - Create: `crates/relicario-cli/src/main.rs` - [ ] **Step 1: Create workspace root Cargo.toml** ```toml # Cargo.toml [workspace] resolver = "2" members = [ "crates/relicario-core", "crates/relicario-cli", ] ``` - [ ] **Step 2: Create relicario-core crate** ```toml # crates/relicario-core/Cargo.toml [package] name = "relicario-core" version = "0.1.0" edition = "2021" description = "Core library for relicario password manager" [dependencies] thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" argon2 = "0.5" chacha20poly1305 = "0.10" rand = "0.8" sha2 = "0.10" ed25519-dalek = { version = "2", features = ["rand_core"] } image = { version = "0.25", default-features = false, features = ["jpeg"] } [dev-dependencies] ``` ```rust // crates/relicario-core/src/lib.rs pub mod error; ``` - [ ] **Step 3: Create relicario-cli crate** ```toml # crates/relicario-cli/Cargo.toml [package] name = "relicario-cli" version = "0.1.0" edition = "2021" description = "CLI for relicario password manager" [[bin]] name = "relicario" path = "src/main.rs" [dependencies] relicario-core = { path = "../relicario-core" } clap = { version = "4", features = ["derive"] } anyhow = "1" rpassword = "5" arboard = "3" dirs = "5" ``` ```rust // crates/relicario-cli/src/main.rs fn main() { println!("relicario v0.1.0"); } ``` - [ ] **Step 4: Verify build** Run: `cargo build` Expected: Compiles with no errors. May show warnings about unused dependencies — that's fine. - [ ] **Step 5: Commit** ```bash git init echo "target/" > .gitignore echo ".superpowers/" >> .gitignore git add Cargo.toml crates/ .gitignore docs/ git commit -m "feat: scaffold Cargo workspace with relicario-core and relicario-cli" ``` --- ### Task 2: Error Types **Files:** - Create: `crates/relicario-core/src/error.rs` - Modify: `crates/relicario-core/src/lib.rs` - [ ] **Step 1: Write the error enum** ```rust // crates/relicario-core/src/error.rs use thiserror::Error; #[derive(Debug, Error)] pub enum RelicarioError { #[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 = std::result::Result; ``` - [ ] **Step 2: Update lib.rs to re-export** ```rust // crates/relicario-core/src/lib.rs pub mod error; pub use error::{RelicarioError, Result}; ``` - [ ] **Step 3: Verify build** Run: `cargo build` Expected: Compiles cleanly. - [ ] **Step 4: Commit** ```bash git add crates/relicario-core/src/error.rs crates/relicario-core/src/lib.rs git commit -m "feat: add RelicarioError enum with thiserror" ``` --- ### Task 3: Crypto — Key Derivation **Files:** - Create: `crates/relicario-core/src/crypto.rs` - Modify: `crates/relicario-core/src/lib.rs` - [ ] **Step 1: Write the failing test** ```rust // crates/relicario-core/src/crypto.rs // ... (implementation comes in step 3) #[cfg(test)] mod tests { use super::*; #[test] fn derive_master_key_deterministic() { let passphrase = b"apple forest thunder mountain"; let image_secret = [0xABu8; 32]; let salt = [0x01u8; 32]; let params = KdfParams::default(); let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); assert_eq!(key1, key2, "same inputs must produce same key"); } #[test] fn derive_master_key_different_passphrase() { let image_secret = [0xABu8; 32]; let salt = [0x01u8; 32]; let params = KdfParams::default(); let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap(); let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap(); assert_ne!(key1, key2); } #[test] fn derive_master_key_different_image_secret() { let passphrase = b"same passphrase"; let salt = [0x01u8; 32]; let params = KdfParams::default(); let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap(); let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap(); assert_ne!(key1, key2); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p relicario-core derive_master_key` Expected: FAIL — `derive_master_key` and `KdfParams` not defined. - [ ] **Step 3: Write the implementation** ```rust // crates/relicario-core/src/crypto.rs use argon2::{Algorithm, Argon2, Params, Version}; use crate::error::{RelicarioError, Result}; /// Argon2id tuning parameters. Stored in .relicario/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 .relicario/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| RelicarioError::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| RelicarioError::Kdf(e.to_string()))?; Ok(output) } #[cfg(test)] mod tests { use super::*; // Use fast params for tests so they don't take forever fn test_params() -> KdfParams { KdfParams { argon2_m: 256, // 256 KiB — fast for tests argon2_t: 1, argon2_p: 1, } } #[test] fn derive_master_key_deterministic() { let passphrase = b"apple forest thunder mountain"; let image_secret = [0xABu8; 32]; let salt = [0x01u8; 32]; let params = test_params(); let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); assert_eq!(key1, key2, "same inputs must produce same key"); } #[test] fn derive_master_key_different_passphrase() { let image_secret = [0xABu8; 32]; let salt = [0x01u8; 32]; let params = test_params(); let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap(); let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap(); assert_ne!(key1, key2); } #[test] fn derive_master_key_different_image_secret() { let passphrase = b"same passphrase"; let salt = [0x01u8; 32]; let params = test_params(); let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap(); let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap(); assert_ne!(key1, key2); } } ``` - [ ] **Step 4: Run tests** Run: `cargo test -p relicario-core derive_master_key` Expected: All 3 tests PASS. - [ ] **Step 5: Update lib.rs** ```rust // crates/relicario-core/src/lib.rs pub mod crypto; pub mod error; pub use crypto::{derive_master_key, KdfParams}; pub use error::{RelicarioError, Result}; ``` - [ ] **Step 6: Commit** ```bash git add crates/relicario-core/src/ git commit -m "feat: add Argon2id key derivation with tests" ``` --- ### Task 4: Crypto — Encrypt / Decrypt **Files:** - Modify: `crates/relicario-core/src/crypto.rs` - [ ] **Step 1: Write the failing tests** Add to `crates/relicario-core/src/crypto.rs` inside the `mod tests` block: ```rust #[test] fn encrypt_decrypt_round_trip() { let key = [0x42u8; 32]; let plaintext = b"hello world, this is a secret"; let ciphertext = encrypt(&key, plaintext).unwrap(); let decrypted = decrypt(&key, &ciphertext).unwrap(); assert_eq!(decrypted, plaintext); } #[test] fn decrypt_wrong_key_fails() { let key = [0x42u8; 32]; let wrong_key = [0x43u8; 32]; let plaintext = b"secret data"; let ciphertext = encrypt(&key, plaintext).unwrap(); let result = decrypt(&wrong_key, &ciphertext); assert!(result.is_err()); } #[test] fn decrypt_tampered_data_fails() { let key = [0x42u8; 32]; let plaintext = b"secret data"; let mut ciphertext = encrypt(&key, plaintext).unwrap(); // Flip a byte in the ciphertext portion (after version + nonce = 25 bytes) if ciphertext.len() > 26 { ciphertext[26] ^= 0xFF; } let result = decrypt(&key, &ciphertext); assert!(result.is_err()); } #[test] fn ciphertext_format_has_correct_structure() { let key = [0x42u8; 32]; let plaintext = b"test"; let ciphertext = encrypt(&key, plaintext).unwrap(); // version(1) + nonce(24) + ciphertext(4) + tag(16) = 45 assert_eq!(ciphertext.len(), 1 + 24 + plaintext.len() + 16); assert_eq!(ciphertext[0], 0x01); // version byte } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test -p relicario-core encrypt` Expected: FAIL — `encrypt` and `decrypt` not defined. - [ ] **Step 3: Write the implementation** Add to `crates/relicario-core/src/crypto.rs`, above the `#[cfg(test)]` block: ```rust use chacha20poly1305::{ aead::{Aead, KeyInit, OsRng}, XChaCha20Poly1305, XNonce, }; use rand::RngCore; /// Current format version byte. const FORMAT_VERSION: u8 = 0x01; /// XChaCha20-Poly1305 nonce size in bytes. const NONCE_SIZE: usize = 24; /// Encrypt plaintext with XChaCha20-Poly1305. /// /// Output format: version(1) || nonce(24) || ciphertext(N) || tag(16) /// Nonce is generated fresh from CSPRNG on each call. pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { 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(|_| RelicarioError::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> { let min_len = 1 + NONCE_SIZE + 16; // version + nonce + tag (empty plaintext) if data.len() < min_len { return Err(RelicarioError::Format(format!( "ciphertext too short: {} bytes, need at least {}", data.len(), min_len ))); } let version = data[0]; if version != FORMAT_VERSION { return Err(RelicarioError::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(|_| RelicarioError::Decrypt) } ``` - [ ] **Step 4: Fix the import for OsRng** The `OsRng` import from chacha20poly1305 may not be re-exported. Update the imports at the top of crypto.rs: ```rust use chacha20poly1305::{ aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce, }; use rand::rngs::OsRng; use rand::RngCore; ``` - [ ] **Step 5: Run all crypto tests** Run: `cargo test -p relicario-core` Expected: All tests PASS (3 KDF tests + 4 encrypt/decrypt tests). - [ ] **Step 6: Update lib.rs exports** ```rust // crates/relicario-core/src/lib.rs pub mod crypto; pub mod error; pub use crypto::{derive_master_key, encrypt, decrypt, KdfParams}; pub use error::{RelicarioError, Result}; ``` - [ ] **Step 7: Commit** ```bash git add crates/relicario-core/src/ git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format" ``` --- ### Task 5: Entry & Manifest Data Model **Files:** - Create: `crates/relicario-core/src/entry.rs` - Modify: `crates/relicario-core/src/lib.rs` - [ ] **Step 1: Write tests for serialization** ```rust // crates/relicario-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 relicario-core entry` Expected: FAIL — types not defined. - [ ] **Step 3: Write the implementation** ```rust // crates/relicario-core/src/entry.rs use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// A single password entry (stored encrypted in entries/.enc). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, pub password: String, #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub totp_secret: Option, 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, #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, pub updated_at: String, } /// The vault manifest — maps entry IDs to their metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { pub entries: HashMap, 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 { self.entries.remove(id) } /// Case-insensitive substring search on name and URL. pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> { let query_lower = query.to_lowercase(); self.entries .iter() .filter(|(_, e)| { e.name.to_lowercase().contains(&query_lower) || e.url .as_ref() .map(|u| u.to_lowercase().contains(&query_lower)) .unwrap_or(false) }) .collect() } } /// Generate a random 8-character hex entry ID. pub fn generate_entry_id() -> String { let mut rng = rand::thread_rng(); let value: u32 = rng.gen(); format!("{:08x}", value) } #[cfg(test)] mod tests { use super::*; #[test] fn entry_serialization_round_trip() { let entry = Entry { name: "GitHub".into(), url: Some("https://github.com/login".into()), username: Some("alee".into()), password: "hunter2".into(), notes: Some("2FA enabled".into()), totp_secret: None, created_at: "2026-04-11T22:30:00Z".into(), updated_at: "2026-04-11T22:30:00Z".into(), }; let json = serde_json::to_string(&entry).unwrap(); let deserialized: Entry = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.name, "GitHub"); assert_eq!(deserialized.password, "hunter2"); } #[test] fn manifest_add_and_lookup() { let mut manifest = Manifest::new(); manifest.add_entry( "a1b2c3d4".into(), ManifestEntry { name: "GitHub".into(), url: Some("https://github.com".into()), username: Some("alee".into()), updated_at: "2026-04-11T22:30:00Z".into(), }, ); assert_eq!(manifest.entries.len(), 1); assert!(manifest.entries.contains_key("a1b2c3d4")); } #[test] fn manifest_serialization_round_trip() { let mut manifest = Manifest::new(); manifest.add_entry( "a1b2c3d4".into(), ManifestEntry { name: "GitHub".into(), url: Some("https://github.com".into()), username: Some("alee".into()), updated_at: "2026-04-11T22:30:00Z".into(), }, ); let json = serde_json::to_string(&manifest).unwrap(); let deserialized: Manifest = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.entries.len(), 1); assert_eq!(deserialized.version, 1); } #[test] fn generate_entry_id_is_8_hex_chars() { let id = generate_entry_id(); assert_eq!(id.len(), 8); assert!(id.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn manifest_search_case_insensitive() { let mut manifest = Manifest::new(); manifest.add_entry( "aaa".into(), ManifestEntry { name: "GitHub".into(), url: Some("https://github.com".into()), username: None, updated_at: "2026-04-11T00:00:00Z".into(), }, ); manifest.add_entry( "bbb".into(), ManifestEntry { name: "Netflix".into(), url: Some("https://netflix.com".into()), username: None, updated_at: "2026-04-11T00:00:00Z".into(), }, ); let results = manifest.search("github"); assert_eq!(results.len(), 1); assert_eq!(results[0].1.name, "GitHub"); let results = manifest.search("flix"); assert_eq!(results.len(), 1); assert_eq!(results[0].1.name, "Netflix"); } } ``` - [ ] **Step 4: Update lib.rs** ```rust // crates/relicario-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::{RelicarioError, Result}; ``` - [ ] **Step 5: Run tests** Run: `cargo test -p relicario-core entry` Expected: All 5 entry tests PASS. - [ ] **Step 6: Commit** ```bash git add crates/relicario-core/src/ git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde" ``` --- ### Task 6: Vault Operations **Files:** - Create: `crates/relicario-core/src/vault.rs` - Modify: `crates/relicario-core/src/lib.rs` - [ ] **Step 1: Write failing tests** ```rust // crates/relicario-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 relicario-core vault` Expected: FAIL — functions not defined. - [ ] **Step 3: Write the implementation** ```rust // crates/relicario-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> { 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 { 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> { 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 { let json = crypto::decrypt(master_key, data)?; let manifest: Manifest = serde_json::from_slice(&json)?; Ok(manifest) } #[cfg(test)] mod tests { use super::*; use crate::entry::{Entry, Manifest, ManifestEntry}; #[test] fn entry_encrypt_decrypt_round_trip() { let key = [0x42u8; 32]; let entry = Entry { name: "GitHub".into(), url: Some("https://github.com".into()), username: Some("alee".into()), password: "secret123".into(), notes: None, totp_secret: None, created_at: "2026-04-11T00:00:00Z".into(), updated_at: "2026-04-11T00:00:00Z".into(), }; let encrypted = encrypt_entry(&key, &entry).unwrap(); let decrypted = decrypt_entry(&key, &encrypted).unwrap(); assert_eq!(decrypted.name, "GitHub"); assert_eq!(decrypted.password, "secret123"); } #[test] fn manifest_encrypt_decrypt_round_trip() { let key = [0x42u8; 32]; let mut manifest = Manifest::new(); manifest.add_entry( "abc123".into(), ManifestEntry { name: "Test".into(), url: None, username: None, updated_at: "2026-04-11T00:00:00Z".into(), }, ); let encrypted = encrypt_manifest(&key, &manifest).unwrap(); let decrypted = decrypt_manifest(&key, &encrypted).unwrap(); assert_eq!(decrypted.entries.len(), 1); assert!(decrypted.entries.contains_key("abc123")); } #[test] fn entry_wrong_key_fails() { let key = [0x42u8; 32]; let wrong_key = [0x43u8; 32]; let entry = Entry { name: "Test".into(), url: None, username: None, password: "pass".into(), notes: None, totp_secret: None, created_at: "2026-04-11T00:00:00Z".into(), updated_at: "2026-04-11T00:00:00Z".into(), }; let encrypted = encrypt_entry(&key, &entry).unwrap(); assert!(decrypt_entry(&wrong_key, &encrypted).is_err()); } } ``` - [ ] **Step 4: Update lib.rs** ```rust // crates/relicario-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::{RelicarioError, Result}; pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; ``` - [ ] **Step 5: Run all tests** Run: `cargo test -p relicario-core` Expected: All tests PASS (KDF + encrypt/decrypt + entry + vault). - [ ] **Step 6: Commit** ```bash git add crates/relicario-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/relicario-core/src/imgsecret.rs` - Modify: `crates/relicario-core/src/lib.rs` This task builds the image-processing foundation. No embedding yet — just: load JPEG → extract luminance → divide into 8×8 blocks → DCT forward/inverse. - [ ] **Step 1: Write tests for DCT round-trip and Y channel extraction** ```rust // crates/relicario-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 { 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 relicario-core imgsecret` Expected: FAIL — functions not defined. - [ ] **Step 3: Write the implementation** ```rust // crates/relicario-core/src/imgsecret.rs use crate::error::{RelicarioError, 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, 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 { let reader = ImageReader::new(Cursor::new(jpeg_bytes)) .with_guessed_format() .map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?; let img = reader .decode() .map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?; let rgb = img.to_rgb8(); let (width, height) = (rgb.width() as usize, rgb.height() as usize); // ITU-R BT.601 luminance conversion let mut y_data = Vec::with_capacity(width * height); for pixel in rgb.pixels() { let r = pixel[0] as f64; let g = pixel[1] as f64; let b = pixel[2] as f64; y_data.push(0.299 * r + 0.587 * g + 0.114 * b); } Ok(YChannel { data: y_data, width, height, }) } /// Calculate the central 70% embedding region (15% margin on each side). pub fn central_region(y: &YChannel) -> EmbedRegion { let margin_x = y.width * 15 / 100; let margin_y = y.height * 15 / 100; let x_offset = margin_x; let y_offset = margin_y; let region_width = y.width - 2 * margin_x; let region_height = y.height - 2 * margin_y; let blocks_x = region_width / BLOCK_SIZE; let blocks_y = region_height / BLOCK_SIZE; EmbedRegion { x_offset, y_offset, region_width, region_height, blocks_x, blocks_y, } } /// Read an 8×8 block from the Y channel at block coordinates (bx, by) /// within the given embedding region. pub fn read_block(y: &YChannel, region: &EmbedRegion, bx: usize, by: usize) -> [f64; 64] { let mut block = [0.0f64; 64]; let px_x = region.x_offset + bx * BLOCK_SIZE; let px_y = region.y_offset + by * BLOCK_SIZE; for row in 0..BLOCK_SIZE { for col in 0..BLOCK_SIZE { let idx = (px_y + row) * y.width + (px_x + col); block[row * BLOCK_SIZE + col] = y.data[idx]; } } block } /// Write an 8×8 block back to the Y channel. pub fn write_block(y: &mut YChannel, region: &EmbedRegion, bx: usize, by: usize, block: &[f64; 64]) { let px_x = region.x_offset + bx * BLOCK_SIZE; let px_y = region.y_offset + by * BLOCK_SIZE; for row in 0..BLOCK_SIZE { for col in 0..BLOCK_SIZE { let idx = (px_y + row) * y.width + (px_x + col); y.data[idx] = block[row * BLOCK_SIZE + col].clamp(0.0, 255.0); } } } /// 2D Type-II DCT on an 8×8 block (separable: rows then columns). pub fn dct2_8x8(block: &[f64; 64]) -> [f64; 64] { let mut temp = [0.0f64; 64]; let mut result = [0.0f64; 64]; // DCT on each row for row in 0..BLOCK_SIZE { let src_offset = row * BLOCK_SIZE; let mut row_data = [0.0f64; BLOCK_SIZE]; row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]); let dct_row = dct1d_8(&row_data); temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&dct_row); } // DCT on each column of the result for col in 0..BLOCK_SIZE { let mut col_data = [0.0f64; BLOCK_SIZE]; for row in 0..BLOCK_SIZE { col_data[row] = temp[row * BLOCK_SIZE + col]; } let dct_col = dct1d_8(&col_data); for row in 0..BLOCK_SIZE { result[row * BLOCK_SIZE + col] = dct_col[row]; } } result } /// 2D inverse DCT on an 8×8 block. pub fn idct2_8x8(block: &[f64; 64]) -> [f64; 64] { let mut temp = [0.0f64; 64]; let mut result = [0.0f64; 64]; // IDCT on each row for row in 0..BLOCK_SIZE { let src_offset = row * BLOCK_SIZE; let mut row_data = [0.0f64; BLOCK_SIZE]; row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]); let idct_row = idct1d_8(&row_data); temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&idct_row); } // IDCT on each column for col in 0..BLOCK_SIZE { let mut col_data = [0.0f64; BLOCK_SIZE]; for row in 0..BLOCK_SIZE { col_data[row] = temp[row * BLOCK_SIZE + col]; } let idct_col = idct1d_8(&col_data); for row in 0..BLOCK_SIZE { result[row * BLOCK_SIZE + col] = idct_col[row]; } } result } /// 1D Type-II DCT for N=8 (orthonormal). fn dct1d_8(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; let n = BLOCK_SIZE as f64; for k in 0..BLOCK_SIZE { let mut sum = 0.0; for i in 0..BLOCK_SIZE { sum += input[i] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos(); } let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() }; output[k] = ck * sum; } output } /// 1D inverse DCT for N=8 (orthonormal). fn idct1d_8(input: &[f64; 8]) -> [f64; 8] { let mut output = [0.0f64; 8]; let n = BLOCK_SIZE as f64; for i in 0..BLOCK_SIZE { let mut sum = 0.0; for k in 0..BLOCK_SIZE { let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() }; sum += ck * input[k] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos(); } output[i] = sum; } output } #[cfg(test)] mod tests { use super::*; #[test] fn dct2_idct2_round_trip() { let block: [f64; 64] = { let mut b = [0.0; 64]; for i in 0..64 { b[i] = (i as f64) * 3.7 - 100.0; } b }; let dct = dct2_8x8(&block); let recovered = idct2_8x8(&dct); for i in 0..64 { assert!( (block[i] - recovered[i]).abs() < 1e-6, "mismatch at index {i}: {} vs {}", block[i], recovered[i] ); } } #[test] fn extract_y_channel_from_synthetic_jpeg() { let jpeg_bytes = make_test_jpeg(64, 64); let y_channel = extract_y_channel(&jpeg_bytes).unwrap(); assert_eq!(y_channel.width, 64); assert_eq!(y_channel.height, 64); assert_eq!(y_channel.data.len(), 64 * 64); } #[test] fn get_blocks_from_region() { let jpeg_bytes = make_test_jpeg(80, 80); let y_channel = extract_y_channel(&jpeg_bytes).unwrap(); let region = central_region(&y_channel); assert!(region.blocks_x > 0); assert!(region.blocks_y > 0); } #[test] fn read_write_block_round_trip() { let jpeg_bytes = make_test_jpeg(80, 80); let mut y_channel = extract_y_channel(&jpeg_bytes).unwrap(); let region = central_region(&y_channel); let original = read_block(&y_channel, ®ion, 0, 0); write_block(&mut y_channel, ®ion, 0, 0, &original); let recovered = read_block(&y_channel, ®ion, 0, 0); for i in 0..64 { assert!((original[i] - recovered[i]).abs() < 1e-10); } } fn make_test_jpeg(width: u32, height: u32) -> Vec { use image::codecs::jpeg::JpegEncoder; use image::{ImageBuffer, ImageEncoder, Rgb}; let img = ImageBuffer::from_fn(width, height, |x, y| { Rgb([ ((x * 7 + y * 13) % 256) as u8, ((x * 11 + y * 3) % 256) as u8, ((x * 5 + y * 17) % 256) as u8, ]) }); let mut buf = Vec::new(); let encoder = JpegEncoder::new_with_quality(&mut buf, 92); encoder .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) .unwrap(); buf } } ``` - [ ] **Step 4: Update lib.rs** ```rust // crates/relicario-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::{RelicarioError, Result}; pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; ``` - [ ] **Step 5: Run tests** Run: `cargo test -p relicario-core imgsecret` Expected: All 4 tests PASS. - [ ] **Step 6: Commit** ```bash git add crates/relicario-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/relicario-core/src/imgsecret.rs` This task adds QIM (Quantization Index Modulation) for embedding/extracting individual bits in DCT coefficients, and the fixed geometric pattern for selecting which blocks carry data. - [ ] **Step 1: Write tests for QIM and block selection** Add to `mod tests` in `imgsecret.rs`: ```rust #[test] fn qim_embed_extract_single_bit() { for coef in [-50.0, -10.0, 0.0, 10.0, 50.0, 127.0] { for bit in [0u8, 1] { let modified = qim_embed(coef, bit, QUANT_STEP); let extracted = qim_extract(modified, QUANT_STEP); assert_eq!(extracted, bit, "failed for coef={coef}, bit={bit}"); } } } #[test] fn qim_survives_small_noise() { let coef = 42.0; let modified = qim_embed(coef, 1, QUANT_STEP); // Add noise smaller than QUANT_STEP/4 let noisy = modified + 3.0; let extracted = qim_extract(noisy, QUANT_STEP); assert_eq!(extracted, 1); } #[test] fn select_embed_blocks_returns_consistent_pattern() { let region = EmbedRegion { x_offset: 12, y_offset: 9, region_width: 56, region_height: 56, blocks_x: 7, blocks_y: 7, }; let blocks1 = select_embed_blocks(®ion, 100); let blocks2 = select_embed_blocks(®ion, 100); assert_eq!(blocks1, blocks2, "pattern must be deterministic"); assert!(!blocks1.is_empty()); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test -p relicario-core qim` Expected: FAIL — `qim_embed`, `qim_extract`, `select_embed_blocks`, `QUANT_STEP` not defined. - [ ] **Step 3: Write QIM and block selection implementation** Add above the `#[cfg(test)]` block in `imgsecret.rs`: ```rust /// QIM quantization step. Larger = more robust, more visible. /// 25 survives JPEG recompression down to ~Q60 on most images. const QUANT_STEP: f64 = 25.0; /// Mid-frequency DCT coefficient positions in zig-zag order. /// Positions 4-15: robust to recompression, visually undetectable. /// Index into the 8×8 block as (row, col). const EMBED_POSITIONS: [(usize, usize); 12] = [ (0, 3), // zig-zag position 4 (1, 2), // 5 (2, 1), // 6 (3, 0), // 7 (0, 4), // 8 (1, 3), // 9 (2, 2), // 10 (3, 1), // 11 (4, 0), // 12 (0, 5), // 13 (1, 4), // 14 (2, 3), // 15 ]; /// Embed a single bit into a DCT coefficient using QIM. /// /// Grid 0: values quantized to 0, Q, 2Q, 3Q, ... /// Grid 1: values quantized to Q/2, 3Q/2, 5Q/2, ... /// To embed bit b, quantize to grid b. pub fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 { let half_q = q / 2.0; let offset = if bit == 1 { half_q } else { 0.0 }; let shifted = coef - offset; let quantized = (shifted / q).round() * q; quantized + offset } /// Extract a single bit from a DCT coefficient using QIM. /// /// Measures distance to grid 0 and grid 1, returns whichever is closer. pub fn qim_extract(coef: f64, q: f64) -> u8 { let half_q = q / 2.0; let dist0 = (coef - (coef / q).round() * q).abs(); let shifted = coef - half_q; let dist1 = (shifted - (shifted / q).round() * q).abs(); if dist0 <= dist1 { 0 } else { 1 } } /// Select embedding block positions using a fixed geometric pattern. /// /// Evenly spaced across the central region. Returns (bx, by) pairs. /// `target_count` is the desired number of blocks; actual may be less /// if the region is small. pub fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> { let total_blocks = region.blocks_x * region.blocks_y; let count = target_count.min(total_blocks); if count == 0 { return vec![]; } // Compute stride to evenly space `count` blocks across the grid let stride = if count >= total_blocks { 1 } else { total_blocks / count }; let mut positions = Vec::with_capacity(count); for i in (0..total_blocks).step_by(stride) { let bx = i % region.blocks_x; let by = i / region.blocks_x; positions.push((bx, by)); if positions.len() >= count { break; } } positions } ``` - [ ] **Step 4: Run tests** Run: `cargo test -p relicario-core imgsecret` Expected: All tests PASS (previous 4 + 3 new QIM/block-selection tests). - [ ] **Step 5: Commit** ```bash git add crates/relicario-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/relicario-core/src/imgsecret.rs` This is the main event: the public `embed()` and `extract()` functions with redundancy coding and majority voting. Reed-Solomon is added in Task 10. - [ ] **Step 1: Write the failing test for round-trip embed/extract** Add to `mod tests`: ```rust #[test] fn embed_extract_round_trip() { let jpeg_bytes = make_test_jpeg(400, 300); let secret = [0xDEu8; 32]; let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); let extracted = extract(&stego_jpeg).unwrap(); assert_eq!(extracted, secret); } #[test] fn embed_extract_random_secret() { let jpeg_bytes = make_test_jpeg(400, 300); let mut secret = [0u8; 32]; rand::thread_rng().fill(&mut secret); let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); let extracted = extract(&stego_jpeg).unwrap(); assert_eq!(extracted, secret); } #[test] fn extract_from_non_embedded_image_fails() { let jpeg_bytes = make_test_jpeg(400, 300); let result = extract(&jpeg_bytes); assert!(result.is_err()); } #[test] fn image_too_small_fails() { let jpeg_bytes = make_test_jpeg(32, 32); let secret = [0xABu8; 32]; let result = embed(&jpeg_bytes, &secret); assert!(result.is_err()); } ``` Add `use rand::Fill;` at the top of the test module for the random fill. - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test -p relicario-core embed_extract` Expected: FAIL — `embed` and `extract` not defined. - [ ] **Step 3: Write embed() implementation** Add to `imgsecret.rs`: ```rust use image::codecs::jpeg::JpegEncoder; use image::{ImageBuffer, ImageEncoder, Rgb}; /// Number of bits per redundant copy of the secret. const SECRET_BITS: usize = 256; // 32 bytes /// Minimum number of redundant copies for reliable extraction. const MIN_COPIES: usize = 5; /// Bits per embedding block (number of mid-freq coefficients used). const BITS_PER_BLOCK: usize = EMBED_POSITIONS.len(); // 12 /// Blocks needed for one copy of the secret. const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // ceil(256/12) = 22 /// Minimum image dimension for embedding. const MIN_DIMENSION: u32 = 100; /// Embed a 256-bit secret into a carrier JPEG. /// Returns the modified JPEG bytes. pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> { 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(RelicarioError::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(RelicarioError::ImgSecret(format!( "image too small for embedding: only {num_copies} copies fit, need at least {MIN_COPIES}" ))); } // Convert secret to bits let secret_bits = bytes_to_bits(secret); // Get all embed block positions let blocks_needed = num_copies * BLOCKS_PER_COPY; let positions = select_embed_blocks(®ion, blocks_needed); // Embed each redundant copy for copy_idx in 0..num_copies { let block_start = copy_idx * BLOCKS_PER_COPY; for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY] .iter() .enumerate() { let mut block = read_block(&y, ®ion, bx, by); let mut dct = dct2_8x8(&block); // Embed up to BITS_PER_BLOCK bits in this block for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx; if bit_idx < SECRET_BITS { let coef_idx = row * BLOCK_SIZE + col; dct[coef_idx] = qim_embed(dct[coef_idx], secret_bits[bit_idx], QUANT_STEP); } } block = idct2_8x8(&dct); write_block(&mut y, ®ion, bx, by, &block); } } // Reconstruct full RGB image with modified Y channel and save as JPEG reconstruct_jpeg(carrier_jpeg, &y) } /// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG. pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { extract_at_offset(jpeg_bytes, 0, 0) } /// Try extraction at a specific pixel offset (for crop recovery). fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]> { let y = extract_y_channel(jpeg_bytes)?; let mut region = central_region(&y); // Apply offset for crop recovery let new_x = region.x_offset as isize + dx; let new_y = region.y_offset as isize + dy; if new_x < 0 || new_y < 0 { return Err(RelicarioError::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(RelicarioError::ExtractionFailed); } let blocks_needed = num_copies * BLOCKS_PER_COPY; let positions = select_embed_blocks(®ion, blocks_needed); // Extract all copies and majority-vote each bit let mut bit_votes = vec![[0u32; 2]; SECRET_BITS]; // votes[bit_idx][0 or 1] for copy_idx in 0..num_copies { let block_start = copy_idx * BLOCKS_PER_COPY; if block_start + BLOCKS_PER_COPY > positions.len() { break; } for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY] .iter() .enumerate() { let block = read_block(&y, ®ion, bx, by); let dct = dct2_8x8(&block); for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() { let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx; if bit_idx < SECRET_BITS { let coef_idx = row * BLOCK_SIZE + col; let bit = qim_extract(dct[coef_idx], QUANT_STEP); bit_votes[bit_idx][bit as usize] += 1; } } } } // Majority vote let mut secret_bits = vec![0u8; SECRET_BITS]; let mut confidence = 0u32; for (i, votes) in bit_votes.iter().enumerate() { if votes[1] > votes[0] { secret_bits[i] = 1; confidence += votes[1]; } else { confidence += votes[0]; } } // Confidence check: if votes are too evenly split, extraction likely failed let total_votes: u32 = bit_votes.iter().map(|v| v[0] + v[1]).sum(); let min_confidence = total_votes * 3 / 4; // at least 75% of votes should agree if confidence < min_confidence { return Err(RelicarioError::ExtractionFailed); } Ok(bits_to_bytes(&secret_bits)) } // ---- Helper functions ---- fn bytes_to_bits(bytes: &[u8]) -> Vec { 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> { let reader = ImageReader::new(Cursor::new(original_jpeg)) .with_guessed_format() .map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?; let img = reader .decode() .map_err(|e| RelicarioError::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, Vec> = 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| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?; Ok(buf) } ``` - [ ] **Step 4: Run tests** Run: `cargo test -p relicario-core imgsecret -- --nocapture` Expected: All tests PASS including embed/extract round-trip. - [ ] **Step 5: Add a JPEG recompression survival test** Add to `mod tests`: ```rust #[test] fn embed_extract_survives_recompression_q85() { let jpeg_bytes = make_test_jpeg(400, 300); let secret = [0xCAu8; 32]; let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); // Re-encode at Q85 (simulating social media) let reader = image::io::Reader::new(Cursor::new(&stego_jpeg)) .with_guessed_format() .unwrap(); let img = reader.decode().unwrap(); let rgb = img.to_rgb8(); let (w, h) = (rgb.width(), rgb.height()); let mut recompressed = Vec::new(); let encoder = JpegEncoder::new_with_quality(&mut recompressed, 85); encoder .write_image(rgb.as_raw(), w, h, image::ExtendedColorType::Rgb8) .unwrap(); let extracted = extract(&recompressed).unwrap(); assert_eq!(extracted, secret, "secret must survive Q85 recompression"); } ``` - [ ] **Step 6: Run all tests** Run: `cargo test -p relicario-core` Expected: All tests PASS. - [ ] **Step 7: Commit** ```bash git add crates/relicario-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/relicario-core/src/imgsecret.rs` - [ ] **Step 1: Write failing crop test** Add to `mod tests`: ```rust #[test] fn embed_extract_survives_10pct_crop() { let jpeg_bytes = make_test_jpeg(400, 300); let secret = [0xBBu8; 32]; let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap(); // Crop 10% from the right edge let reader = image::io::Reader::new(Cursor::new(&stego_jpeg)) .with_guessed_format() .unwrap(); let img = reader.decode().unwrap(); let (w, h) = (img.width(), img.height()); let crop_pixels = w * 10 / 100; let cropped = img.crop_imm(0, 0, w - crop_pixels, h); let rgb = cropped.to_rgb8(); let mut cropped_jpeg = Vec::new(); let encoder = JpegEncoder::new_with_quality(&mut cropped_jpeg, 92); encoder .write_image( rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8, ) .unwrap(); let extracted = extract_with_crop_recovery(&cropped_jpeg).unwrap(); assert_eq!(extracted, secret, "secret must survive 10% crop"); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p relicario-core crop` Expected: FAIL — `extract_with_crop_recovery` not defined. - [ ] **Step 3: Write crop recovery implementation** Add to `imgsecret.rs`: ```rust /// Extract with crop recovery — tries multiple offsets if canonical extraction fails. pub fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { // Try canonical alignment first (fast path) if let Ok(secret) = extract(jpeg_bytes) { return Ok(secret); } // Determine search range from image dimensions let y = extract_y_channel(jpeg_bytes)?; let max_dx = (y.width as isize) * 15 / 100; let max_dy = (y.height as isize) * 15 / 100; let step = BLOCK_SIZE as isize; // 8 pixels // Search crop offsets for dy in (-max_dy..=max_dy).step_by(step as usize) { for dx in (-max_dx..=max_dx).step_by(step as usize) { if dx == 0 && dy == 0 { continue; // already tried canonical } if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) { return Ok(secret); } } } Err(RelicarioError::ExtractionFailed) } ``` Also update the public `extract()` to call `extract_with_crop_recovery()`: Replace the existing `extract` function: ```rust /// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG. /// Automatically tries crop recovery if canonical extraction fails. pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { extract_with_crop_recovery(jpeg_bytes) } /// Internal: try extraction at canonical (0,0) offset only. fn extract_canonical(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { extract_at_offset(jpeg_bytes, 0, 0) } /// Extract with crop recovery — tries multiple offsets if canonical extraction fails. fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { // Try canonical alignment first (fast path) if let Ok(secret) = extract_canonical(jpeg_bytes) { return Ok(secret); } // Determine search range from image dimensions let y = extract_y_channel(jpeg_bytes)?; let max_dx = (y.width as isize) * 15 / 100; let max_dy = (y.height as isize) * 15 / 100; let step = BLOCK_SIZE as isize; for dy in (-max_dy..=max_dy).step_by(step as usize) { for dx in (-max_dx..=max_dx).step_by(step as usize) { if dx == 0 && dy == 0 { continue; } if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) { return Ok(secret); } } } Err(RelicarioError::ExtractionFailed) } ``` - [ ] **Step 4: Run all imgsecret tests** Run: `cargo test -p relicario-core imgsecret -- --nocapture` Expected: All tests PASS including crop recovery. - [ ] **Step 5: Commit** ```bash git add crates/relicario-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/relicario-cli/src/main.rs` - [ ] **Step 1: Write the clap CLI structure** ```rust // crates/relicario-cli/src/main.rs use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use relicario_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 = "relicario", 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 relicario_dir() -> PathBuf { vault_dir().join(".relicario") } fn read_salt() -> Result<[u8; 32]> { let bytes = fs::read(relicario_dir().join("salt")) .context("failed to read .relicario/salt — is this a vault directory?")?; let mut salt = [0u8; 32]; salt.copy_from_slice(&bytes); Ok(salt) } fn read_params() -> Result { let json = fs::read_to_string(relicario_dir().join("params.json")) .context("failed to read .relicario/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 = relicario_core::imgsecret::extract(&jpeg_bytes) .map_err(|e| anyhow::anyhow!("failed to extract image secret: {e}"))?; let salt = read_salt()?; let params = read_params()?; let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) .map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?; Ok(master_key) } /// Get reference image path — from env var RELICARIO_IMAGE or prompt. fn get_image_path() -> Result { if let Ok(path) = std::env::var("RELICARIO_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 { 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 = relicario_core::imgsecret::embed(&carrier_jpeg, &image_secret) .map_err(|e| anyhow::anyhow!("failed to embed secret: {e}"))?; fs::write(output_path, &stego_jpeg) .context("failed to write reference image")?; println!("Reference image saved to: {}", output_path.display()); // 3. Prompt for passphrase let passphrase = rpassword::prompt_password("Choose a passphrase: ")?; let passphrase_confirm = rpassword::prompt_password("Confirm passphrase: ")?; if passphrase != passphrase_confirm { anyhow::bail!("passphrases do not match"); } if passphrase.len() < 8 { anyhow::bail!("passphrase must be at least 8 characters"); } // 4. Generate salt and derive key let mut salt = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); let params = KdfParams::default(); println!("Deriving master key (this may take a moment)..."); let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) .map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?; // 5. Write vault structure fs::create_dir_all(relicario_dir())?; fs::create_dir_all(vault_dir().join("entries"))?; fs::write(relicario_dir().join("salt"), salt)?; fs::write( relicario_dir().join("params.json"), serde_json::to_string_pretty(¶ms)?, )?; fs::write(relicario_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 relicario 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 { 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> { let input = prompt(message)?; Ok(if input.is_empty() { None } else { Some(input) }) } fn prompt_with_default(field: &str, current: &str) -> Result { let display = if current.is_empty() { format!("{field}: ") } else { format!("{field} [{current}]: ") }; let input = prompt(&display)?; Ok(if input.is_empty() { current.to_string() } else { input }) } fn generate_password(length: usize) -> String { use rand::Rng; const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; let mut rng = rand::thread_rng(); (0..length) .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char) .collect() } ``` - [ ] **Step 2: Verify build** Run: `cargo build` Expected: Compiles with no errors. - [ ] **Step 3: Test generate command** Run: `cargo run -- generate` Expected: Prints a random 20-character password. Run: `cargo run -- generate -l 32` Expected: Prints a random 32-character password. - [ ] **Step 4: Test help output** Run: `cargo run -- --help` Expected: Shows all subcommands with descriptions. - [ ] **Step 5: Commit** ```bash git add crates/relicario-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/relicario-cli/src/main.rs` - [ ] **Step 1: Add device subcommands to the CLI** Add to the `Commands` enum: ```rust /// Manage trusted devices Device { #[command(subcommand)] action: DeviceCommands, }, ``` Add the subcommand enum: ```rust #[derive(Subcommand)] enum DeviceCommands { /// Register this device Add { /// Device name #[arg(long)] name: String, }, /// List authorized devices List, /// Revoke a device Revoke { /// Device name to revoke name: String, }, } ``` Add the match arm in `main()`: ```rust Commands::Device { action } => match action { DeviceCommands::Add { name } => cmd_device_add(&name), DeviceCommands::List => cmd_device_list(), DeviceCommands::Revoke { name } => cmd_device_revoke(&name), }, ``` - [ ] **Step 2: Write device management functions** Add to `main.rs`: ```rust use ed25519_dalek::{SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] struct DeviceEntry { name: String, public_key: String, // hex-encoded ed25519 public key } fn read_devices() -> Result> { let json = fs::read_to_string(relicario_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(relicario_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("relicario"); 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/relicario-cli/Cargo.toml` under `[dependencies]`: ```toml hex = "0.4" ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" ``` - [ ] **Step 4: Verify build** Run: `cargo build` Expected: Compiles cleanly. - [ ] **Step 5: Commit** ```bash git add crates/relicario-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/relicario-core/tests/integration.rs` This test exercises the full flow: generate secret → embed → derive key → encrypt entry → decrypt entry → extract secret from re-encoded image. - [ ] **Step 1: Write the integration test** ```rust // crates/relicario-core/tests/integration.rs use relicario_core::*; use relicario_core::imgsecret; fn make_test_jpeg(width: u32, height: u32) -> Vec { use image::codecs::jpeg::JpegEncoder; use image::{ImageBuffer, ImageEncoder, Rgb}; let img = ImageBuffer::from_fn(width, height, |x, y| { Rgb([ ((x * 7 + y * 13) % 256) as u8, ((x * 11 + y * 3) % 256) as u8, ((x * 5 + y * 17) % 256) as u8, ]) }); let mut buf = Vec::new(); let encoder = JpegEncoder::new_with_quality(&mut buf, 92); encoder .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) .unwrap(); buf } #[test] fn full_vault_workflow() { // 1. Generate an image_secret and embed it let carrier = make_test_jpeg(400, 300); let mut image_secret = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret); let reference_jpeg = imgsecret::embed(&carrier, &image_secret).unwrap(); // 2. Extract the secret back let extracted_secret = imgsecret::extract(&reference_jpeg).unwrap(); assert_eq!(extracted_secret, image_secret); // 3. Derive master key let passphrase = b"correct horse battery staple"; let mut salt = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt); let params = KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1, }; let master_key = derive_master_key(passphrase, &extracted_secret, &salt, ¶ms).unwrap(); // 4. Create and encrypt an entry let entry = Entry { name: "GitHub".into(), url: Some("https://github.com".into()), username: Some("alee".into()), password: "supersecret123!".into(), notes: None, totp_secret: None, created_at: "2026-04-11T00:00:00Z".into(), updated_at: "2026-04-11T00:00:00Z".into(), }; let encrypted = encrypt_entry(&master_key, &entry).unwrap(); // 5. Decrypt and verify let decrypted = decrypt_entry(&master_key, &encrypted).unwrap(); assert_eq!(decrypted.name, "GitHub"); assert_eq!(decrypted.password, "supersecret123!"); assert_eq!(decrypted.username.as_deref(), Some("alee")); // 6. Wrong passphrase must fail let wrong_key = derive_master_key(b"wrong", &extracted_secret, &salt, ¶ms).unwrap(); assert!(decrypt_entry(&wrong_key, &encrypted).is_err()); // 7. Wrong image_secret must fail let wrong_secret = [0xFFu8; 32]; let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, ¶ms).unwrap(); assert!(decrypt_entry(&wrong_key2, &encrypted).is_err()); // 8. Manifest round-trip let mut manifest = Manifest::new(); manifest.add_entry( "abc123".into(), ManifestEntry { name: "GitHub".into(), url: Some("https://github.com".into()), username: Some("alee".into()), updated_at: "2026-04-11T00:00:00Z".into(), }, ); let enc_manifest = encrypt_manifest(&master_key, &manifest).unwrap(); let dec_manifest = decrypt_manifest(&master_key, &enc_manifest).unwrap(); assert_eq!(dec_manifest.entries.len(), 1); assert!(dec_manifest.entries.contains_key("abc123")); } #[test] fn two_factor_independence() { // Verifies that changing EITHER factor produces a different key let carrier = make_test_jpeg(400, 300); let image_secret = [0xAAu8; 32]; let salt = [0x01u8; 32]; let params = KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1, }; let key_original = derive_master_key(b"passphrase", &image_secret, &salt, ¶ms).unwrap(); // Different passphrase, same image → different key let key_diff_pass = derive_master_key(b"other", &image_secret, &salt, ¶ms).unwrap(); assert_ne!(key_original, key_diff_pass); // Same passphrase, different image → different key let key_diff_img = derive_master_key(b"passphrase", &[0xBBu8; 32], &salt, ¶ms).unwrap(); assert_ne!(key_original, key_diff_img); // Both different → different key let key_both = derive_master_key(b"other", &[0xBBu8; 32], &salt, ¶ms).unwrap(); assert_ne!(key_original, key_both); assert_ne!(key_diff_pass, key_both); assert_ne!(key_diff_img, key_both); } ``` - [ ] **Step 2: Run integration tests** Run: `cargo test -p relicario-core --test integration` Expected: Both tests PASS. - [ ] **Step 3: Run the full test suite** Run: `cargo test` Expected: ALL tests across all crates PASS. - [ ] **Step 4: Commit** ```bash git add crates/relicario-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: - **relicario-wasm**: wasm-bindgen wrapper around relicario-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.