From 166f1418f7545a94c6d035b02dd4681273cd1070 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:37:56 -0400 Subject: [PATCH 01/69] chore(core): add deps for typed-item rewrite zeroize, zxcvbn, bip39, unicode-normalization, chrono, hex, url, getrandom. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/Cargo.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/idfoto-core/Cargo.toml b/crates/idfoto-core/Cargo.toml index 1ef5ad5..e7cff87 100644 --- a/crates/idfoto-core/Cargo.toml +++ b/crates/idfoto-core/Cargo.toml @@ -15,4 +15,15 @@ sha2 = "0.10" ed25519-dalek = { version = "2", features = ["rand_core"] } image = { version = "0.25", default-features = false, features = ["jpeg"] } +# Typed-item additions +zeroize = { version = "1", features = ["zeroize_derive"] } +zxcvbn = { version = "3", default-features = false } +bip39 = { version = "2", default-features = false, features = ["std"] } +unicode-normalization = "0.1" +chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +hex = "0.4" +url = { version = "2", features = ["serde"] } +getrandom = "0.2" + [dev-dependencies] +hex = "0.4" From 9a5ae2c704534c09b660f89c4bda1b0523cf17f0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:42:27 -0400 Subject: [PATCH 02/69] chore(core): add chrono wasmbind feature for WASM target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review flagged that chrono's clock feature requires wasmbind for WASM builds — without it Utc::now() will fail at runtime in the idfoto-wasm crate. Also drops the redundant hex entry in [dev-dependencies] (already in [dependencies]). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/Cargo.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/idfoto-core/Cargo.toml b/crates/idfoto-core/Cargo.toml index e7cff87..2372e51 100644 --- a/crates/idfoto-core/Cargo.toml +++ b/crates/idfoto-core/Cargo.toml @@ -20,10 +20,8 @@ zeroize = { version = "1", features = ["zeroize_derive"] } zxcvbn = { version = "3", default-features = false } bip39 = { version = "2", default-features = false, features = ["std"] } unicode-normalization = "0.1" -chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] } hex = "0.4" url = { version = "2", features = ["serde"] } getrandom = "0.2" -[dev-dependencies] -hex = "0.4" From 69c2c7453b00bf425e3fb064f7c152622d9ed257 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:47:23 -0400 Subject: [PATCH 03/69] feat(core): add ItemId, FieldId, AttachmentId types 16-char hex (64-bit) random IDs for items and fields (audit M8). AttachmentId is sha256(plaintext)[..16] for content-addressing. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/ids.rs | 108 ++++++++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 3 + 2 files changed, 111 insertions(+) create mode 100644 crates/idfoto-core/src/ids.rs diff --git a/crates/idfoto-core/src/ids.rs b/crates/idfoto-core/src/ids.rs new file mode 100644 index 0000000..9da53a0 --- /dev/null +++ b/crates/idfoto-core/src/ids.rs @@ -0,0 +1,108 @@ +//! Random and content-addressed identifiers for items, fields, and attachments. +//! +//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy) +//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format). +//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` — +//! content-addressed so identical plaintext blobs deduplicate naturally in git. + +use rand::rngs::OsRng; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ItemId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct FieldId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AttachmentId(pub String); + +impl ItemId { + pub fn new() -> Self { + let mut bytes = [0u8; 8]; + OsRng.fill_bytes(&mut bytes); + Self(hex::encode(bytes)) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +impl FieldId { + pub fn new() -> Self { + let mut bytes = [0u8; 8]; + OsRng.fill_bytes(&mut bytes); + Self(hex::encode(bytes)) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +impl AttachmentId { + pub fn from_plaintext(plaintext: &[u8]) -> Self { + let digest = Sha256::digest(plaintext); + Self(hex::encode(&digest[..8])) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_id_is_16_hex_chars() { + let id = ItemId::new(); + assert_eq!(id.0.len(), 16); + assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn item_ids_are_unique() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..10_000 { + assert!(seen.insert(ItemId::new().0)); + } + } + + #[test] + fn field_id_is_16_hex_chars() { + let id = FieldId::new(); + assert_eq!(id.0.len(), 16); + assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn attachment_id_is_deterministic() { + let plaintext = b"hello world"; + let a = AttachmentId::from_plaintext(plaintext); + let b = AttachmentId::from_plaintext(plaintext); + assert_eq!(a, b); + } + + #[test] + fn attachment_id_changes_with_plaintext() { + let a = AttachmentId::from_plaintext(b"hello"); + let b = AttachmentId::from_plaintext(b"world"); + assert_ne!(a, b); + } + + #[test] + fn attachment_id_is_16_hex_chars() { + let id = AttachmentId::from_plaintext(b"any bytes"); + assert_eq!(id.0.len(), 16); + assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn ids_serialize_as_bare_strings() { + let item = ItemId("abcdef0123456789".to_string()); + let json = serde_json::to_string(&item).unwrap(); + assert_eq!(json, "\"abcdef0123456789\""); + + let parsed: ItemId = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, item); + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index bce5854..7e11a53 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -35,6 +35,9 @@ pub mod error; pub use error::{IdfotoError, Result}; +pub mod ids; +pub use ids::{AttachmentId, FieldId, ItemId}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; From 6c601fae087f93814c3e5f3b460ce1781e9b3a90 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:50:24 -0400 Subject: [PATCH 04/69] chore(core): add Default impls + FieldId uniqueness test Code review fixups: - ItemId/FieldId need impl Default delegating to ::new() to silence clippy::new_without_default - FieldId was missing the parallel uniqueness test that ItemId has Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/ids.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/idfoto-core/src/ids.rs b/crates/idfoto-core/src/ids.rs index 9da53a0..58c403e 100644 --- a/crates/idfoto-core/src/ids.rs +++ b/crates/idfoto-core/src/ids.rs @@ -31,6 +31,10 @@ impl ItemId { pub fn as_str(&self) -> &str { &self.0 } } +impl Default for ItemId { + fn default() -> Self { Self::new() } +} + impl FieldId { pub fn new() -> Self { let mut bytes = [0u8; 8]; @@ -40,6 +44,10 @@ impl FieldId { pub fn as_str(&self) -> &str { &self.0 } } +impl Default for FieldId { + fn default() -> Self { Self::new() } +} + impl AttachmentId { pub fn from_plaintext(plaintext: &[u8]) -> Self { let digest = Sha256::digest(plaintext); @@ -74,6 +82,14 @@ mod tests { assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } + #[test] + fn field_ids_are_unique() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..10_000 { + assert!(seen.insert(FieldId::new().0)); + } + } + #[test] fn attachment_id_is_deterministic() { let plaintext = b"hello world"; From 1e8ffb02a3536bbdc3af709a6fa8b3e02084bb31 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:51:35 -0400 Subject: [PATCH 05/69] feat(core): add now_unix() and MonthYear MonthYear used for card expiries; bounds 2000..=2099 are intentional. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/lib.rs | 3 ++ crates/idfoto-core/src/time.rs | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 crates/idfoto-core/src/time.rs diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 7e11a53..a43801d 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -38,6 +38,9 @@ pub use error::{IdfotoError, Result}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; +pub mod time; +pub use time::{now_unix, MonthYear}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; diff --git a/crates/idfoto-core/src/time.rs b/crates/idfoto-core/src/time.rs new file mode 100644 index 0000000..979df76 --- /dev/null +++ b/crates/idfoto-core/src/time.rs @@ -0,0 +1,63 @@ +//! Time helpers and the `MonthYear` type used for card expiries. + +use serde::{Deserialize, Serialize}; + +/// Current Unix timestamp in seconds. +pub fn now_unix() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Month + year (1-12 / e.g. 2026). Used for card expiries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct MonthYear { + pub month: u8, + pub year: u16, +} + +impl MonthYear { + pub fn new(month: u8, year: u16) -> Result { + if !(1..=12).contains(&month) { + return Err("month must be 1..=12"); + } + if year < 2000 || year > 2099 { + return Err("year must be 2000..=2099"); + } + Ok(Self { month, year }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn now_unix_is_positive_and_recent() { + let t = now_unix(); + assert!(t > 1_700_000_000); // after late 2023 + assert!(t < 4_000_000_000); // before 2096 + } + + #[test] + fn month_year_constructor_rejects_bad_month() { + assert!(MonthYear::new(0, 2026).is_err()); + assert!(MonthYear::new(13, 2026).is_err()); + assert!(MonthYear::new(1, 2026).is_ok()); + assert!(MonthYear::new(12, 2026).is_ok()); + } + + #[test] + fn month_year_constructor_rejects_bad_year() { + assert!(MonthYear::new(1, 1999).is_err()); + assert!(MonthYear::new(1, 2100).is_err()); + assert!(MonthYear::new(1, 2000).is_ok()); + assert!(MonthYear::new(1, 2099).is_ok()); + } + + #[test] + fn month_year_round_trips_through_json() { + let my = MonthYear::new(7, 2030).unwrap(); + let json = serde_json::to_string(&my).unwrap(); + let parsed: MonthYear = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, my); + } +} From 1bd86bdb135d3a1f5df015e0cbeb90ae1f7483fc Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:53:28 -0400 Subject: [PATCH 06/69] refactor(core): rewrite IdfotoError variants for typed items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decrypt is now opaque (audit M4) - Add WeakPassphrase, AttachmentTooLarge, ItemNotFound, UnsupportedFormatVersion - Rename EntryNotFound → ItemNotFound across remaining call sites Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/error.rs | 94 ++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/crates/idfoto-core/src/error.rs b/crates/idfoto-core/src/error.rs index 194c5f5..cd9be18 100644 --- a/crates/idfoto-core/src/error.rs +++ b/crates/idfoto-core/src/error.rs @@ -10,50 +10,41 @@ 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 +/// KDF -> encryption -> decryption -> format parsing -> item 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")] + /// Authenticated decryption failed. Message intentionally opaque (audit M4). + #[error("decryption failed")] 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), + #[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")] + UnsupportedFormatVersion { found: u8, expected: u8 }, + + /// An item was looked up by ID but does not exist in the manifest. + #[error("item not found: {0}")] + ItemNotFound(String), + + /// A passphrase failed the strength gate at vault creation (audit H3). + #[error("passphrase strength insufficient (score {score}/4)")] + WeakPassphrase { score: u8 }, + + /// An attachment exceeded the per-attachment cap from VaultSettings. + #[error("attachment too large: {size} bytes > {max} bytes max")] + AttachmentTooLarge { size: u64, max: u64 }, - /// 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, @@ -62,25 +53,56 @@ 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; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decrypt_error_message_is_opaque() { + let err = IdfotoError::Decrypt; + assert_eq!(format!("{}", err), "decryption failed"); + } + + #[test] + fn weak_passphrase_carries_score() { + let err = IdfotoError::WeakPassphrase { score: 1 }; + let s = format!("{}", err); + assert!(s.contains("passphrase")); + assert!(s.contains("strength")); + } + + #[test] + fn attachment_too_large_reports_sizes() { + let err = IdfotoError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 }; + let s = format!("{}", err); + assert!(s.contains("11000000")); + assert!(s.contains("10485760")); + } + + #[test] + fn item_not_found_carries_id() { + let err = IdfotoError::ItemNotFound("abc123".to_string()); + assert!(format!("{}", err).contains("abc123")); + } + + #[test] + fn unsupported_format_version_reports_byte() { + let err = IdfotoError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }; + let s = format!("{}", err); + assert!(s.contains("01") || s.contains("1")); + assert!(s.contains("02") || s.contains("2")); + } +} From 2ea765803669b8fcae5ce0143ec5b198bdc659a8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:57:58 -0400 Subject: [PATCH 07/69] feat(core): length-prefixed Argon2 input + NFC + Zeroize (audit H1, H2) derive_master_key now: - length-prefixes passphrase and image_secret to eliminate concatenation ambiguity (H1) - normalizes passphrase to UTF-8 NFC before hashing - returns Zeroizing<[u8; 32]> so the master key is wiped on drop (H2) - wraps the intermediate password buffer in Zeroizing for the same reason Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 607 +++++++++++++++++++++++++++++++ crates/idfoto-cli/Cargo.toml | 1 + crates/idfoto-cli/src/main.rs | 33 +- crates/idfoto-core/src/crypto.rs | 80 +++- 4 files changed, 693 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac775d7..f67cdf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -106,6 +124,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-trait" version = "0.1.89" @@ -129,6 +153,41 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -217,6 +276,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -289,6 +362,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -367,6 +446,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -409,6 +497,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -434,6 +533,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "errno" version = "0.3.14" @@ -450,6 +555,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fax" version = "0.2.6" @@ -501,6 +617,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -581,6 +706,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hmac" version = "0.12.1" @@ -590,6 +724,112 @@ dependencies = [ "digest", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "idfoto-cli" version = "0.1.0" @@ -605,6 +845,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "zeroize", ] [[package]] @@ -612,14 +853,22 @@ name = "idfoto-core" version = "0.1.0" dependencies = [ "argon2", + "bip39", "chacha20poly1305", + "chrono", "ed25519-dalek", + "getrandom", + "hex", "image", "rand", "serde", "serde_json", "sha2", "thiserror 2.0.18", + "unicode-normalization", + "url", + "zeroize", + "zxcvbn", ] [[package]] @@ -630,6 +879,7 @@ dependencies = [ "getrandom", "hmac", "idfoto-core", + "image", "js-sys", "serde_json", "sha1", @@ -637,6 +887,27 @@ dependencies = [ "wasm-bindgen-test", ] +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -668,6 +939,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -686,6 +966,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.184" @@ -713,6 +999,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -773,6 +1065,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -966,6 +1264,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1055,6 +1368,35 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rpassword" version = "5.0.1" @@ -1222,6 +1564,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1245,6 +1593,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1299,6 +1658,50 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.19.0" @@ -1311,6 +1714,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -1321,6 +1733,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1443,6 +1874,16 @@ version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472" +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "weezl" version = "0.1.12" @@ -1480,12 +1921,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -1635,6 +2129,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x11rb" version = "0.13.2" @@ -1652,6 +2152,29 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -1672,11 +2195,79 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zmij" @@ -1698,3 +2289,19 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zxcvbn" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" +dependencies = [ + "chrono", + "fancy-regex", + "itertools", + "lazy_static", + "regex", + "time", + "wasm-bindgen", + "web-sys", +] diff --git a/crates/idfoto-cli/Cargo.toml b/crates/idfoto-cli/Cargo.toml index 7b91654..dd1b7ed 100644 --- a/crates/idfoto-cli/Cargo.toml +++ b/crates/idfoto-cli/Cargo.toml @@ -20,3 +20,4 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" +zeroize = "1" diff --git a/crates/idfoto-cli/src/main.rs b/crates/idfoto-cli/src/main.rs index b5b7bfb..8a27d5b 100644 --- a/crates/idfoto-cli/src/main.rs +++ b/crates/idfoto-cli/src/main.rs @@ -43,6 +43,7 @@ use idfoto_core::{ decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry, }; +use zeroize::Zeroizing; use rand::rngs::OsRng; use rand::RngCore; use serde::{Deserialize, Serialize}; @@ -201,7 +202,7 @@ fn get_image_path() -> Result { /// 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]> { +fn unlock(image_path: &PathBuf) -> Result> { let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?; let jpeg_data = fs::read(image_path).context("failed to read reference image")?; @@ -389,7 +390,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { // 10. Encrypt empty manifest let manifest = Manifest::new(); - let manifest_enc = encrypt_manifest(&master_key, &manifest).context("failed to encrypt manifest")?; + let manifest_enc = encrypt_manifest(&*master_key, &manifest).context("failed to encrypt manifest")?; fs::write(vault_dir().join("manifest.enc"), manifest_enc) .context("failed to write manifest.enc")?; @@ -463,14 +464,14 @@ fn cmd_add() -> Result<()> { }; let entry_id = generate_entry_id(); - let encrypted = encrypt_entry(&master_key, &entry).context("failed to encrypt entry")?; + let encrypted = encrypt_entry(&*master_key, &entry).context("failed to encrypt entry")?; fs::write( vault_dir().join("entries").join(format!("{}.enc", entry_id)), encrypted, ) .context("failed to write entry file")?; - let mut manifest = read_manifest(&master_key)?; + let mut manifest = read_manifest(&*master_key)?; manifest.add_entry( entry_id.clone(), ManifestEntry { @@ -481,7 +482,7 @@ fn cmd_add() -> Result<()> { updated_at: now, }, ); - write_manifest(&master_key, &manifest)?; + write_manifest(&*master_key, &manifest)?; git_commit(&format!("feat: add entry '{}'", name))?; eprintln!("Entry '{}' added (id: {})", name, entry_id); @@ -534,12 +535,12 @@ fn cmd_get(query: String) -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; - let manifest = read_manifest(&master_key)?; + let manifest = read_manifest(&*master_key)?; let (entry_id, _) = search_and_select(&manifest, &query)?; let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id))) .context("failed to read entry file")?; - let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?; + let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?; println!("Name: {}", entry.name); println!( @@ -595,7 +596,7 @@ fn cmd_list() -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; - let manifest = read_manifest(&master_key)?; + let manifest = read_manifest(&*master_key)?; let mut entries: Vec<_> = manifest.entries.iter().collect(); entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase())); @@ -626,12 +627,12 @@ fn cmd_edit(query: String) -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; - let manifest = read_manifest(&master_key)?; + let manifest = read_manifest(&*master_key)?; let (entry_id, _) = search_and_select(&manifest, &query)?; let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id))) .context("failed to read entry file")?; - let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?; + let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?; eprintln!("Editing '{}' (Enter to keep current value)", entry.name); @@ -667,14 +668,14 @@ fn cmd_edit(query: String) -> Result<()> { updated_at: now.clone(), }; - let encrypted = encrypt_entry(&master_key, &updated_entry).context("failed to encrypt entry")?; + let encrypted = encrypt_entry(&*master_key, &updated_entry).context("failed to encrypt entry")?; fs::write( vault_dir().join("entries").join(format!("{}.enc", entry_id)), encrypted, ) .context("failed to write entry file")?; - let mut manifest = read_manifest(&master_key)?; + let mut manifest = read_manifest(&*master_key)?; manifest.add_entry( entry_id, ManifestEntry { @@ -685,7 +686,7 @@ fn cmd_edit(query: String) -> Result<()> { updated_at: now, }, ); - write_manifest(&master_key, &manifest)?; + write_manifest(&*master_key, &manifest)?; git_commit(&format!("feat: edit entry '{}'", name))?; eprintln!("Entry '{}' updated.", name); @@ -701,7 +702,7 @@ fn cmd_rm(query: String) -> Result<()> { let image_path = get_image_path()?; let master_key = unlock(&image_path)?; - let manifest = read_manifest(&master_key)?; + let manifest = read_manifest(&*master_key)?; let (entry_id, entry) = search_and_select(&manifest, &query)?; let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?; @@ -717,9 +718,9 @@ fn cmd_rm(query: String) -> Result<()> { fs::remove_file(&entry_path).context("failed to remove entry file")?; } - let mut manifest = read_manifest(&master_key)?; + let mut manifest = read_manifest(&*master_key)?; manifest.remove_entry(&entry_id); - write_manifest(&master_key, &manifest)?; + write_manifest(&*master_key, &manifest)?; git_commit(&format!("feat: remove entry '{}'", entry.name))?; eprintln!("Entry '{}' removed.", entry.name); diff --git a/crates/idfoto-core/src/crypto.rs b/crates/idfoto-core/src/crypto.rs index 319beb3..fea56ec 100644 --- a/crates/idfoto-core/src/crypto.rs +++ b/crates/idfoto-core/src/crypto.rs @@ -50,6 +50,8 @@ use chacha20poly1305::{ }; use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; use crate::error::{IdfotoError, Result}; @@ -207,7 +209,7 @@ pub fn derive_master_key( image_secret: &[u8; 32], salt: &[u8; 32], params: &KdfParams, -) -> Result<[u8; 32]> { +) -> Result> { let argon2_params = Params::new( params.argon2_m, params.argon2_t, @@ -218,17 +220,24 @@ pub fn derive_master_key( let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); - // 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); + // Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged. + let nfc_passphrase: Vec = match std::str::from_utf8(passphrase) { + Ok(s) => s.nfc().collect::().into_bytes(), + Err(_) => passphrase.to_vec(), + }; + + // Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase] + // [u64_be(32)][image_secret] + // Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1). + let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32)); + password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes()); + password.extend_from_slice(&nfc_passphrase); + password.extend_from_slice(&32u64.to_be_bytes()); password.extend_from_slice(image_secret); - let mut output = [0u8; 32]; + let mut output = Zeroizing::new([0u8; 32]); argon2 - .hash_password_into(&password, salt, &mut output) + .hash_password_into(password.as_slice(), salt, output.as_mut()) .map_err(|e| IdfotoError::Kdf(e.to_string()))?; Ok(output) @@ -256,7 +265,7 @@ mod tests { 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); + assert_eq!(*key1, *key2); } #[test] @@ -268,7 +277,7 @@ mod tests { 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); + assert_ne!(*key1, *key2); } #[test] @@ -283,7 +292,7 @@ mod tests { let key1 = derive_master_key(passphrase, &image_secret1, &salt, ¶ms).unwrap(); let key2 = derive_master_key(passphrase, &image_secret2, &salt, ¶ms).unwrap(); - assert_ne!(key1, key2); + assert_ne!(*key1, *key2); } #[test] @@ -338,4 +347,51 @@ mod tests { // Version byte must be 0x01 assert_eq!(ciphertext[0], 0x01); } + + #[test] + fn length_prefix_eliminates_concatenation_ambiguity() { + // Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide. + // With length-prefix: distinct inputs always yield distinct keys. + let salt = [0u8; 32]; + let params = fast_params(); + + // Pair A: passphrase "abc", image_secret starts with 0x44 + let mut img_a = [0u8; 32]; img_a[0] = 0x44; + let key_a = derive_master_key(b"abc", &img_a, &salt, ¶ms).unwrap(); + + // Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1 + let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image + let key_b = derive_master_key(b"abcD", &img_b, &salt, ¶ms).unwrap(); + + // With length-prefix, the keys MUST differ. + assert_ne!(*key_a, *key_b); + } + + #[test] + fn nfc_normalization_collapses_unicode_forms() { + // "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301). + // Both must produce the same key after NFC normalization. + let salt = [0u8; 32]; + let img = [0u8; 32]; + let params = fast_params(); + + let nfc = "caf\u{00e9}".as_bytes(); // é precomposed + let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute + + let key_nfc = derive_master_key(nfc, &img, &salt, ¶ms).unwrap(); + let key_nfd = derive_master_key(nfd, &img, &salt, ¶ms).unwrap(); + + assert_eq!(*key_nfc, *key_nfd); + } + + #[test] + fn master_key_is_zeroized_on_drop() { + // Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if + // we wrap correctly. The drop wipe is verified by the zeroize crate's tests. + let salt = [0u8; 32]; + let img = [0u8; 32]; + let params = fast_params(); + let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, ¶ms).unwrap(); + assert_eq!(key.len(), 32); + } } From 87ead533e5666508d34e401259c36b915dff6069 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 10:48:49 -0400 Subject: [PATCH 08/69] feat(core): bump VERSION_BYTE to 0x02 with typed UnsupportedFormatVersion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean break from v1 — no migration. Decrypting a v1 blob now returns IdfotoError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/crypto.rs | 43 ++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/idfoto-core/src/crypto.rs b/crates/idfoto-core/src/crypto.rs index fea56ec..e2d7fa8 100644 --- a/crates/idfoto-core/src/crypto.rs +++ b/crates/idfoto-core/src/crypto.rs @@ -22,7 +22,7 @@ //! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable] //! ``` //! -//! - **Version byte** (`0x01`): allows future format changes without ambiguity. +//! - **Version byte** (`0x02`): 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 @@ -56,7 +56,7 @@ use zeroize::Zeroizing; use crate::error::{IdfotoError, Result}; /// Current binary format version. Increment this if the ciphertext layout changes. -const VERSION_BYTE: u8 = 0x01; +pub const VERSION_BYTE: u8 = 0x02; /// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes. const NONCE_LEN: usize = 24; @@ -123,12 +123,12 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result> { )); } - let version = data[0]; - if version != VERSION_BYTE { - return Err(IdfotoError::Format(format!( - "unknown version byte: 0x{:02x}", - version - ))); + let found = data[0]; + if found != VERSION_BYTE { + return Err(IdfotoError::UnsupportedFormatVersion { + found, + expected: VERSION_BYTE, + }); } let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]); @@ -344,8 +344,8 @@ mod tests { let expected_len = 1 + 24 + plaintext.len() + 16; assert_eq!(ciphertext.len(), expected_len); - // Version byte must be 0x01 - assert_eq!(ciphertext[0], 0x01); + // Version byte must be 0x02 + assert_eq!(ciphertext[0], 0x02); } #[test] @@ -394,4 +394,27 @@ mod tests { let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, ¶ms).unwrap(); assert_eq!(key.len(), 32); } + + #[test] + fn version_byte_is_0x02() { + assert_eq!(VERSION_BYTE, 0x02); + } + + #[test] + fn decrypt_rejects_v1_blob_with_typed_error() { + // Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes]. + let mut blob = vec![0x01u8]; + blob.extend_from_slice(&[0u8; 24]); + blob.extend_from_slice(&[0u8; 16]); + + let key = Zeroizing::new([0u8; 32]); + let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt"); + match err { + IdfotoError::UnsupportedFormatVersion { found, expected } => { + assert_eq!(found, 0x01); + assert_eq!(expected, 0x02); + } + other => panic!("expected UnsupportedFormatVersion, got {:?}", other), + } + } } From 0eac9c7991fce7f49b438a5ca240f4816101f1a4 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 12:55:34 -0400 Subject: [PATCH 09/69] feat(core): scaffold item_types module with ItemType + ItemCore enum Stub LoginCore, SecureNoteCore, IdentityCore, CardCore, KeyCore, DocumentCore, TotpCore. Tag-based serde representation with snake_case discriminants. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/card.rs | 15 ++++ crates/idfoto-core/src/item_types/document.rs | 3 + crates/idfoto-core/src/item_types/identity.rs | 3 + crates/idfoto-core/src/item_types/key.rs | 3 + crates/idfoto-core/src/item_types/login.rs | 3 + crates/idfoto-core/src/item_types/mod.rs | 73 +++++++++++++++++++ .../idfoto-core/src/item_types/secure_note.rs | 3 + crates/idfoto-core/src/item_types/totp.rs | 28 +++++++ crates/idfoto-core/src/lib.rs | 3 + 9 files changed, 134 insertions(+) create mode 100644 crates/idfoto-core/src/item_types/card.rs create mode 100644 crates/idfoto-core/src/item_types/document.rs create mode 100644 crates/idfoto-core/src/item_types/identity.rs create mode 100644 crates/idfoto-core/src/item_types/key.rs create mode 100644 crates/idfoto-core/src/item_types/login.rs create mode 100644 crates/idfoto-core/src/item_types/mod.rs create mode 100644 crates/idfoto-core/src/item_types/secure_note.rs create mode 100644 crates/idfoto-core/src/item_types/totp.rs diff --git a/crates/idfoto-core/src/item_types/card.rs b/crates/idfoto-core/src/item_types/card.rs new file mode 100644 index 0000000..481eae3 --- /dev/null +++ b/crates/idfoto-core/src/item_types/card.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CardCore {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum CardKind { + #[default] + Credit, + Debit, + Gift, + Loyalty, + Other, +} diff --git a/crates/idfoto-core/src/item_types/document.rs b/crates/idfoto-core/src/item_types/document.rs new file mode 100644 index 0000000..5a16eb4 --- /dev/null +++ b/crates/idfoto-core/src/item_types/document.rs @@ -0,0 +1,3 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DocumentCore {} diff --git a/crates/idfoto-core/src/item_types/identity.rs b/crates/idfoto-core/src/item_types/identity.rs new file mode 100644 index 0000000..fa89ba2 --- /dev/null +++ b/crates/idfoto-core/src/item_types/identity.rs @@ -0,0 +1,3 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IdentityCore {} diff --git a/crates/idfoto-core/src/item_types/key.rs b/crates/idfoto-core/src/item_types/key.rs new file mode 100644 index 0000000..011d1b9 --- /dev/null +++ b/crates/idfoto-core/src/item_types/key.rs @@ -0,0 +1,3 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct KeyCore {} diff --git a/crates/idfoto-core/src/item_types/login.rs b/crates/idfoto-core/src/item_types/login.rs new file mode 100644 index 0000000..4ab7756 --- /dev/null +++ b/crates/idfoto-core/src/item_types/login.rs @@ -0,0 +1,3 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LoginCore {} diff --git a/crates/idfoto-core/src/item_types/mod.rs b/crates/idfoto-core/src/item_types/mod.rs new file mode 100644 index 0000000..eb6d2d1 --- /dev/null +++ b/crates/idfoto-core/src/item_types/mod.rs @@ -0,0 +1,73 @@ +//! Per-type "core" structs for typed items. +//! +//! Each variant lives in its own submodule. The `ItemCore` enum + match +//! exhaustiveness is the extension mechanism — adding a new variant later +//! means: create the submodule, add the enum variant, fix the match arms +//! the compiler points at, register the popup form (Plan 1C). + +use serde::{Deserialize, Serialize}; + +pub mod login; +pub mod secure_note; +pub mod identity; +pub mod card; +pub mod key; +pub mod document; +pub mod totp; + +pub use login::LoginCore; +pub use secure_note::SecureNoteCore; +pub use identity::IdentityCore; +pub use card::{CardCore, CardKind}; +pub use key::KeyCore; +pub use document::DocumentCore; +pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemType { + Login, + SecureNote, + Identity, + Card, + Key, + Document, + Totp, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ItemCore { + Login(LoginCore), + SecureNote(SecureNoteCore), + Identity(IdentityCore), + Card(CardCore), + Key(KeyCore), + Document(DocumentCore), + Totp(TotpCore), +} + +impl ItemCore { + pub fn item_type(&self) -> ItemType { + match self { + ItemCore::Login(_) => ItemType::Login, + ItemCore::SecureNote(_) => ItemType::SecureNote, + ItemCore::Identity(_) => ItemType::Identity, + ItemCore::Card(_) => ItemType::Card, + ItemCore::Key(_) => ItemType::Key, + ItemCore::Document(_) => ItemType::Document, + ItemCore::Totp(_) => ItemType::Totp, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_type_serializes_snake_case() { + let json = serde_json::to_string(&ItemType::SecureNote).unwrap(); + assert_eq!(json, "\"secure_note\""); + } +} diff --git a/crates/idfoto-core/src/item_types/secure_note.rs b/crates/idfoto-core/src/item_types/secure_note.rs new file mode 100644 index 0000000..271c222 --- /dev/null +++ b/crates/idfoto-core/src/item_types/secure_note.rs @@ -0,0 +1,3 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SecureNoteCore {} diff --git a/crates/idfoto-core/src/item_types/totp.rs b/crates/idfoto-core/src/item_types/totp.rs new file mode 100644 index 0000000..253398e --- /dev/null +++ b/crates/idfoto-core/src/item_types/totp.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TotpCore {} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TotpConfig {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TotpAlgorithm { + #[default] + Sha1, + Sha256, + Sha512, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TotpKind { + Totp, + Hotp { counter: u64 }, + Steam, +} + +impl Default for TotpKind { + fn default() -> Self { TotpKind::Totp } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index a43801d..00637fe 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -41,6 +41,9 @@ pub use ids::{AttachmentId, FieldId, ItemId}; pub mod time; pub use time::{now_unix, MonthYear}; +pub mod item_types; +pub use item_types::{ItemCore, ItemType}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; From bc60f0a6b4195e8e9df7f6868853bfe7a94cf3b1 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 12:58:43 -0400 Subject: [PATCH 10/69] docs(core): add "type" tag-collision invariant to ItemCore Reviewer note: flatten semantics of serde tag = "type" means no *Core struct may ever use "type" as a field name. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/idfoto-core/src/item_types/mod.rs b/crates/idfoto-core/src/item_types/mod.rs index eb6d2d1..f8a6e22 100644 --- a/crates/idfoto-core/src/item_types/mod.rs +++ b/crates/idfoto-core/src/item_types/mod.rs @@ -35,6 +35,9 @@ pub enum ItemType { Totp, } +// INVARIANT: no *Core struct may have a field serialized as "type" — +// that key is reserved for serde's internal tag. Use "kind" for +// type-discriminant fields within core structs (CardKind, TotpKind). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ItemCore { From 24ed7407183ded566f9dff1c7968d8c712dcb1dd Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 12:59:46 -0400 Subject: [PATCH 11/69] feat(core): flesh out LoginCore with Zeroizing and Url Also enables zeroize's `serde` feature so Zeroizing can round-trip through serde_json. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/idfoto-core/Cargo.toml | 2 +- crates/idfoto-core/src/item_types/login.rs | 62 +++++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f67cdf3..3d95328 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,6 +2222,7 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ + "serde", "zeroize_derive", ] diff --git a/crates/idfoto-core/Cargo.toml b/crates/idfoto-core/Cargo.toml index 2372e51..0ae3a70 100644 --- a/crates/idfoto-core/Cargo.toml +++ b/crates/idfoto-core/Cargo.toml @@ -16,7 +16,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } image = { version = "0.25", default-features = false, features = ["jpeg"] } # Typed-item additions -zeroize = { version = "1", features = ["zeroize_derive"] } +zeroize = { version = "1", features = ["zeroize_derive", "serde"] } zxcvbn = { version = "3", default-features = false } bip39 = { version = "2", default-features = false, features = ["std"] } unicode-normalization = "0.1" diff --git a/crates/idfoto-core/src/item_types/login.rs b/crates/idfoto-core/src/item_types/login.rs index 4ab7756..d7a4ce7 100644 --- a/crates/idfoto-core/src/item_types/login.rs +++ b/crates/idfoto-core/src/item_types/login.rs @@ -1,3 +1,63 @@ +//! Login item core: username, password (Zeroizing), URL, optional TOTP. + use serde::{Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use crate::item_types::TotpConfig; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct LoginCore {} +pub struct LoginCore { + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub totp: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_login_round_trips() { + let login = LoginCore::default(); + let json = serde_json::to_string(&login).unwrap(); + let parsed: LoginCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.username.is_none()); + assert!(parsed.password.is_none()); + } + + #[test] + fn full_login_round_trips() { + let login = LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com/login").unwrap()), + totp: None, + }; + let json = serde_json::to_string(&login).unwrap(); + let parsed: LoginCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.username.as_deref(), Some("alice")); + assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2")); + assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login")); + } + + #[test] + fn omitted_fields_dont_appear_in_json() { + let login = LoginCore { + username: Some("alice".into()), + password: None, + url: None, + totp: None, + }; + let json = serde_json::to_string(&login).unwrap(); + assert!(!json.contains("password")); + assert!(!json.contains("url")); + assert!(!json.contains("totp")); + assert!(json.contains("alice")); + } +} From ee25ffed4139136f994fd6e665de9d214339715b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:03:03 -0400 Subject: [PATCH 12/69] feat(core): flesh out SecureNoteCore (Zeroizing body) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../idfoto-core/src/item_types/secure_note.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/item_types/secure_note.rs b/crates/idfoto-core/src/item_types/secure_note.rs index 271c222..20fe52d 100644 --- a/crates/idfoto-core/src/item_types/secure_note.rs +++ b/crates/idfoto-core/src/item_types/secure_note.rs @@ -1,3 +1,30 @@ +//! Secure note: just a multiline body, Zeroizing. + use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SecureNoteCore {} +pub struct SecureNoteCore { + pub body: Zeroizing, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secure_note_round_trips() { + let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) }; + let json = serde_json::to_string(¬e).unwrap(); + let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.body.as_str(), "a multi\nline note"); + } + + #[test] + fn empty_body_round_trips() { + let note = SecureNoteCore::default(); + let json = serde_json::to_string(¬e).unwrap(); + let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.body.is_empty()); + } +} From 316036832cc9da34b035687d0938ceff78b1b914 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:04:23 -0400 Subject: [PATCH 13/69] feat(core): flesh out IdentityCore Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/identity.rs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/item_types/identity.rs b/crates/idfoto-core/src/item_types/identity.rs index fa89ba2..a7485dc 100644 --- a/crates/idfoto-core/src/item_types/identity.rs +++ b/crates/idfoto-core/src/item_types/identity.rs @@ -1,3 +1,45 @@ +//! Identity: name, address, phone, email, date-of-birth. + +use chrono::NaiveDate; use serde::{Deserialize, Serialize}; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct IdentityCore {} +pub struct IdentityCore { + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_full_round_trip() { + let id = IdentityCore { + full_name: Some("Alice Doe".into()), + address: Some("123 Main St\nAnytown".into()), + phone: Some("+1-555-0100".into()), + email: Some("alice@example.com".into()), + date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18), + }; + let json = serde_json::to_string(&id).unwrap(); + let parsed: IdentityCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe")); + assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18)); + } + + #[test] + fn empty_identity_omits_all_fields() { + let id = IdentityCore::default(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "{}"); + } +} From 0707628d58dc9d54b296d09f5d477306d85aeb26 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:05:36 -0400 Subject: [PATCH 14/69] feat(core): flesh out CardCore + CardKind Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/card.rs | 55 ++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/item_types/card.rs b/crates/idfoto-core/src/item_types/card.rs index 481eae3..55247d6 100644 --- a/crates/idfoto-core/src/item_types/card.rs +++ b/crates/idfoto-core/src/item_types/card.rs @@ -1,7 +1,25 @@ +//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind. + use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::time::MonthYear; #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CardCore {} +pub struct CardCore { + #[serde(skip_serializing_if = "Option::is_none")] + pub number: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub holder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cvv: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin: Option>, + #[serde(default)] + pub kind: CardKind, +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -13,3 +31,38 @@ pub enum CardKind { Loyalty, Other, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_full_round_trip() { + let card = CardCore { + number: Some(Zeroizing::new("4111111111111111".into())), + holder: Some("Alice Doe".into()), + expiry: Some(MonthYear::new(12, 2030).unwrap()), + cvv: Some(Zeroizing::new("123".into())), + pin: Some(Zeroizing::new("0000".into())), + kind: CardKind::Credit, + }; + let json = serde_json::to_string(&card).unwrap(); + let parsed: CardCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.holder.as_deref(), Some("Alice Doe")); + assert_eq!(parsed.kind, CardKind::Credit); + assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap())); + } + + #[test] + fn card_kind_default_is_credit() { + let json = "{}"; + let card: CardCore = serde_json::from_str(json).unwrap(); + assert_eq!(card.kind, CardKind::Credit); + } + + #[test] + fn card_kind_serializes_snake_case() { + let json = serde_json::to_string(&CardKind::Loyalty).unwrap(); + assert_eq!(json, "\"loyalty\""); + } +} From 0b0f1cea7377e9988a7fe66d557c822b17cec566 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:06:50 -0400 Subject: [PATCH 15/69] feat(core): flesh out KeyCore Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/key.rs | 41 +++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/item_types/key.rs b/crates/idfoto-core/src/item_types/key.rs index 011d1b9..2a5c52a 100644 --- a/crates/idfoto-core/src/item_types/key.rs +++ b/crates/idfoto-core/src/item_types/key.rs @@ -1,3 +1,42 @@ +//! Key: arbitrary key material (Zeroizing), label, public key, algorithm. + use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct KeyCore {} +pub struct KeyCore { + pub key_material: Zeroizing, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub algorithm: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_round_trip() { + let k = KeyCore { + key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()), + label: Some("yubikey-backup".into()), + public_key: Some("ssh-ed25519 AAAAC3...".into()), + algorithm: Some("ed25519".into()), + }; + let json = serde_json::to_string(&k).unwrap(); + let parsed: KeyCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.key_material.starts_with("-----BEGIN")); + assert_eq!(parsed.algorithm.as_deref(), Some("ed25519")); + } + + #[test] + fn empty_key_material_round_trips() { + let k = KeyCore::default(); + let json = serde_json::to_string(&k).unwrap(); + let parsed: KeyCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.key_material.is_empty()); + } +} From 5786d9ef1aa978e33c33d9566128410a59943740 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:08:03 -0400 Subject: [PATCH 16/69] feat(core): flesh out DocumentCore Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/document.rs | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/idfoto-core/src/item_types/document.rs b/crates/idfoto-core/src/item_types/document.rs index 5a16eb4..d6e8714 100644 --- a/crates/idfoto-core/src/item_types/document.rs +++ b/crates/idfoto-core/src/item_types/document.rs @@ -1,3 +1,40 @@ +//! Document: filename + mime + pointer to the primary attachment blob. + use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DocumentCore {} + +use crate::ids::AttachmentId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentCore { + pub filename: String, + pub mime_type: String, + pub primary_attachment: AttachmentId, +} + +impl Default for DocumentCore { + fn default() -> Self { + Self { + filename: String::new(), + mime_type: "application/octet-stream".into(), + primary_attachment: AttachmentId(String::new()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_round_trip() { + let doc = DocumentCore { + filename: "passport.pdf".into(), + mime_type: "application/pdf".into(), + primary_attachment: AttachmentId("0123456789abcdef".into()), + }; + let json = serde_json::to_string(&doc).unwrap(); + let parsed: DocumentCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.filename, "passport.pdf"); + assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef"); + } +} From 91b4b5b7a47b10ec770ff86bc833646accc5bfa6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:09:34 -0400 Subject: [PATCH 17/69] feat(core): flesh out TotpCore + TotpConfig + TotpAlgorithm + TotpKind Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/totp.rs | 88 +++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/crates/idfoto-core/src/item_types/totp.rs b/crates/idfoto-core/src/item_types/totp.rs index 253398e..1b6c52f 100644 --- a/crates/idfoto-core/src/item_types/totp.rs +++ b/crates/idfoto-core/src/item_types/totp.rs @@ -1,10 +1,38 @@ +//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login. + use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TotpCore {} +pub struct TotpCore { + pub config: TotpConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TotpConfig {} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TotpConfig { + /// Raw bytes of the TOTP secret (decoded from base32 when imported). + pub secret: Zeroizing>, + pub algorithm: TotpAlgorithm, + pub digits: u8, + pub period_seconds: u32, + pub kind: TotpKind, +} + +impl Default for TotpConfig { + fn default() -> Self { + Self { + secret: Zeroizing::new(Vec::new()), + algorithm: TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: TotpKind::Totp, + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -15,7 +43,7 @@ pub enum TotpAlgorithm { Sha512, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TotpKind { Totp, @@ -26,3 +54,55 @@ pub enum TotpKind { impl Default for TotpKind { fn default() -> Self { TotpKind::Totp } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn totp_default_is_sha1_6_30_totp() { + let cfg = TotpConfig::default(); + assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1); + assert_eq!(cfg.digits, 6); + assert_eq!(cfg.period_seconds, 30); + assert_eq!(cfg.kind, TotpKind::Totp); + } + + #[test] + fn totp_round_trip() { + let core = TotpCore { + config: TotpConfig { + secret: Zeroizing::new(vec![0x12, 0x34, 0x56]), + algorithm: TotpAlgorithm::Sha256, + digits: 8, + period_seconds: 60, + kind: TotpKind::Totp, + }, + issuer: Some("github".into()), + label: Some("alice@github".into()), + }; + let json = serde_json::to_string(&core).unwrap(); + let parsed: TotpCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.config.digits, 8); + assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256); + assert_eq!(parsed.issuer.as_deref(), Some("github")); + } + + #[test] + fn hotp_carries_counter() { + let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() }; + let json = serde_json::to_string(&cfg).unwrap(); + let parsed: TotpConfig = serde_json::from_str(&json).unwrap(); + match parsed.kind { + TotpKind::Hotp { counter } => assert_eq!(counter, 42), + other => panic!("expected Hotp, got {:?}", other), + } + } + + #[test] + fn steam_kind_serializes() { + let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() }; + let json = serde_json::to_string(&cfg).unwrap(); + assert!(json.contains("steam")); + } +} From a95f92fe71b75634238b4aabcd4fee0bdcbb5ed5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:11:08 -0400 Subject: [PATCH 18/69] test(core): exhaustive round-trip for all seven ItemCore variants Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item_types/mod.rs | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/idfoto-core/src/item_types/mod.rs b/crates/idfoto-core/src/item_types/mod.rs index f8a6e22..6f3452e 100644 --- a/crates/idfoto-core/src/item_types/mod.rs +++ b/crates/idfoto-core/src/item_types/mod.rs @@ -73,4 +73,56 @@ mod tests { let json = serde_json::to_string(&ItemType::SecureNote).unwrap(); assert_eq!(json, "\"secure_note\""); } + + #[test] + fn item_core_login_round_trip_via_tag() { + use zeroize::Zeroizing; + let core = ItemCore::Login(LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: None, + totp: None, + }); + let json = serde_json::to_string(&core).unwrap(); + // Tag-based: outer object has "type": "login" + assert!(json.contains("\"type\":\"login\"")); + let parsed: ItemCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.item_type(), ItemType::Login); + } + + #[test] + fn item_core_secure_note_round_trip_via_tag() { + use zeroize::Zeroizing; + let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) }); + let json = serde_json::to_string(&core).unwrap(); + assert!(json.contains("\"type\":\"secure_note\"")); + let parsed: ItemCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.item_type(), ItemType::SecureNote); + } + + #[test] + fn item_core_round_trips_for_all_seven_types() { + use zeroize::Zeroizing; + use crate::ids::AttachmentId; + + let cores = vec![ + ItemCore::Login(LoginCore::default()), + ItemCore::SecureNote(SecureNoteCore::default()), + ItemCore::Identity(IdentityCore::default()), + ItemCore::Card(CardCore::default()), + ItemCore::Key(KeyCore::default()), + ItemCore::Document(DocumentCore { + filename: "x".into(), + mime_type: "text/plain".into(), + primary_attachment: AttachmentId("0123456789abcdef".into()), + }), + ItemCore::Totp(TotpCore::default()), + ]; + for core in cores { + let expected_type = core.item_type(); + let json = serde_json::to_string(&core).unwrap(); + let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed"); + assert_eq!(parsed.item_type(), expected_type); + } + } } From 23f7cb76b122c8a801e03296789bcc52f1a9522b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:14:00 -0400 Subject: [PATCH 19/69] feat(core): add Field, FieldKind, FieldValue, Section Parallel kind/value enums with a validate() invariant. Password, Concealed, and Totp kinds are marked history-tracked so the Item setter (next task) can decide whether to capture history on update. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item.rs | 185 +++++++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 3 + 2 files changed, 188 insertions(+) create mode 100644 crates/idfoto-core/src/item.rs diff --git a/crates/idfoto-core/src/item.rs b/crates/idfoto-core/src/item.rs new file mode 100644 index 0000000..e52ce2e --- /dev/null +++ b/crates/idfoto-core/src/item.rs @@ -0,0 +1,185 @@ +//! Item envelope, sections, and custom fields. +//! +//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing +//! to a single tagged enum) so the kind can be queried without inspecting the value. +//! Validation invariant: kind and value's discriminants must match — enforced at +//! construction (`Field::new`) and during deserialization (`Field::validate`). + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use crate::error::{IdfotoError, Result}; +use crate::ids::{AttachmentId, FieldId}; +use crate::item_types::TotpConfig; +use crate::time::MonthYear; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldKind { + Text, + Multiline, + Password, + Concealed, + Url, + Email, + Phone, + Date, + MonthYear, + Totp, + Reference, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum FieldValue { + Text(String), + Multiline(String), + Password(Zeroizing), + Concealed(Zeroizing), + Url(Url), + Email(String), + Phone(String), + Date(NaiveDate), + MonthYear(MonthYear), + Totp(TotpConfig), + Reference(AttachmentId), +} + +impl FieldValue { + pub fn kind(&self) -> FieldKind { + match self { + FieldValue::Text(_) => FieldKind::Text, + FieldValue::Multiline(_) => FieldKind::Multiline, + FieldValue::Password(_) => FieldKind::Password, + FieldValue::Concealed(_) => FieldKind::Concealed, + FieldValue::Url(_) => FieldKind::Url, + FieldValue::Email(_) => FieldKind::Email, + FieldValue::Phone(_) => FieldKind::Phone, + FieldValue::Date(_) => FieldKind::Date, + FieldValue::MonthYear(_) => FieldKind::MonthYear, + FieldValue::Totp(_) => FieldKind::Totp, + FieldValue::Reference(_) => FieldKind::Reference, + } + } + + /// True if this kind triggers field-history capture on update. + pub fn is_history_tracked(&self) -> bool { + matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Field { + pub id: FieldId, + pub label: String, + pub kind: FieldKind, + pub value: FieldValue, + #[serde(default)] + pub hidden_by_default: bool, +} + +impl Field { + /// Construct a field, deriving `kind` from `value`. + pub fn new(label: String, value: FieldValue) -> Self { + let kind = value.kind(); + Self { + id: FieldId::new(), + label, + kind, + value, + hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed), + } + } + + /// Verify kind/value discriminants match. Called after deserialization. + pub fn validate(&self) -> Result<()> { + if self.kind != self.value.kind() { + return Err(IdfotoError::Format(format!( + "field {}: kind {:?} does not match value discriminant {:?}", + self.id.as_str(), + self.kind, + self.value.kind() + ))); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Section { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub fields: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_value_kind_matches() { + let v = FieldValue::Text("hello".into()); + assert_eq!(v.kind(), FieldKind::Text); + } + + #[test] + fn password_field_marked_history_tracked() { + assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked()); + assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked()); + assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked()); + assert!(!FieldValue::Text("x".into()).is_history_tracked()); + assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked()); + } + + #[test] + fn field_new_derives_kind_from_value() { + let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into()))); + assert_eq!(f.kind, FieldKind::Password); + assert!(f.hidden_by_default); + } + + #[test] + fn field_new_text_not_hidden() { + let f = Field::new("Username".into(), FieldValue::Text("alice".into())); + assert!(!f.hidden_by_default); + } + + #[test] + fn field_validate_catches_kind_value_mismatch() { + let f = Field { + id: FieldId::new(), + label: "x".into(), + kind: FieldKind::Password, + value: FieldValue::Text("not actually a password".into()), + hidden_by_default: false, + }; + assert!(f.validate().is_err()); + } + + #[test] + fn field_round_trips() { + let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into()))); + let json = serde_json::to_string(&f).unwrap(); + let parsed: Field = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.label, "Recovery code"); + assert_eq!(parsed.kind, FieldKind::Concealed); + parsed.validate().unwrap(); + } + + #[test] + fn section_round_trip() { + let s = Section { + name: Some("Recovery codes".into()), + fields: vec![ + Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))), + Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))), + ], + }; + let json = serde_json::to_string(&s).unwrap(); + let parsed: Section = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("Recovery codes")); + assert_eq!(parsed.fields.len(), 2); + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 00637fe..49f50ad 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -44,6 +44,9 @@ pub use time::{now_unix, MonthYear}; pub mod item_types; pub use item_types::{ItemCore, ItemType}; +pub mod item; +pub use item::{Field, FieldKind, FieldValue, Section}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; From 509db707e092934d43c4762cdca743640a8e3d07 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:16:15 -0400 Subject: [PATCH 20/69] feat(core): add AttachmentRef + AttachmentSummary Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/attachment.rs | 75 ++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 3 ++ 2 files changed, 78 insertions(+) create mode 100644 crates/idfoto-core/src/attachment.rs diff --git a/crates/idfoto-core/src/attachment.rs b/crates/idfoto-core/src/attachment.rs new file mode 100644 index 0000000..8af857c --- /dev/null +++ b/crates/idfoto-core/src/attachment.rs @@ -0,0 +1,75 @@ +//! Attachment refs (carried on Item) and summaries (carried in Manifest). +//! +//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added +//! later in Task 22 once the crypto module is settled. + +use serde::{Deserialize, Serialize}; + +use crate::ids::AttachmentId; + +/// Reference to an attachment, carried on the Item record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentRef { + pub id: AttachmentId, + pub filename: String, + pub mime_type: String, + /// Plaintext size in bytes. + pub size: u64, + /// Unix-seconds when this attachment was added. + pub created: i64, +} + +/// Compact summary of an attachment, carried in the Manifest so the popup +/// can show attachment indicators without decrypting the item file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentSummary { + pub id: AttachmentId, + pub filename: String, + pub mime_type: String, + pub size: u64, +} + +impl From<&AttachmentRef> for AttachmentSummary { + fn from(r: &AttachmentRef) -> Self { + Self { + id: r.id.clone(), + filename: r.filename.clone(), + mime_type: r.mime_type.clone(), + size: r.size, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attachment_ref_round_trip() { + let r = AttachmentRef { + id: AttachmentId("0123456789abcdef".into()), + filename: "doc.pdf".into(), + mime_type: "application/pdf".into(), + size: 12345, + created: 1_700_000_000, + }; + let json = serde_json::to_string(&r).unwrap(); + let parsed: AttachmentRef = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.filename, "doc.pdf"); + assert_eq!(parsed.size, 12345); + } + + #[test] + fn attachment_summary_from_ref() { + let r = AttachmentRef { + id: AttachmentId("aabb".into()), + filename: "x.txt".into(), + mime_type: "text/plain".into(), + size: 5, + created: 0, + }; + let s: AttachmentSummary = (&r).into(); + assert_eq!(s.filename, "x.txt"); + assert_eq!(s.id, r.id); + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 49f50ad..bfed8db 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -47,6 +47,9 @@ pub use item_types::{ItemCore, ItemType}; pub mod item; pub use item::{Field, FieldKind, FieldValue, Section}; +pub mod attachment; +pub use attachment::{AttachmentRef, AttachmentSummary}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; From a5ddbf2e40deef621e7298811adeb40a95d0977a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 14:25:11 -0400 Subject: [PATCH 21/69] feat(core): add Item envelope with field history + soft-delete set_field_value() captures old values for Password, Concealed, and Totp kinds. Soft-delete via trashed_at timestamp; restore clears it. Kind changes on set_field_value are rejected. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item.rs | 236 +++++++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 2 +- 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/item.rs b/crates/idfoto-core/src/item.rs index e52ce2e..3237e03 100644 --- a/crates/idfoto-core/src/item.rs +++ b/crates/idfoto-core/src/item.rs @@ -114,6 +114,146 @@ pub struct Section { pub fields: Vec, } +use std::collections::HashMap; + +use crate::attachment::AttachmentRef; +use crate::ids::ItemId; +use crate::item_types::{ItemCore, ItemType}; +use crate::time::now_unix; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldHistoryEntry { + pub value: Zeroizing, + pub replaced_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: ItemId, + pub title: String, + pub r#type: ItemType, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created: i64, + pub modified: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub trashed_at: Option, + pub core: ItemCore, + #[serde(default)] + pub sections: Vec
, + #[serde(default)] + pub attachments: Vec, + #[serde(default)] + pub field_history: HashMap>, +} + +impl Item { + /// Construct a new Item from a typed core; auto-fills id, type, timestamps. + pub fn new(title: String, core: ItemCore) -> Self { + let now = now_unix(); + let r#type = core.item_type(); + Self { + id: ItemId::new(), + title, + r#type, + tags: Vec::new(), + favorite: false, + group: None, + notes: None, + created: now, + modified: now, + trashed_at: None, + core, + sections: Vec::new(), + attachments: Vec::new(), + field_history: HashMap::new(), + } + } + + /// Replace a custom field's value, capturing the previous value into + /// field_history if the field's kind is history-tracked. + pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> { + for section in &mut self.sections { + if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) { + if field.value.kind() != new_value.kind() { + return Err(IdfotoError::Format(format!( + "field {}: cannot change kind from {:?} to {:?}", + field.id.as_str(), field.value.kind(), new_value.kind() + ))); + } + if field.value.is_history_tracked() { + let serialized = serialize_history_value(&field.value)?; + self.field_history + .entry(field.id.clone()) + .or_default() + .push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() }); + } + field.value = new_value; + self.modified = now_unix(); + return Ok(()); + } + } + Err(IdfotoError::Format(format!("field {} not found", field_id.as_str()))) + } + + pub fn soft_delete(&mut self) { + self.trashed_at = Some(now_unix()); + self.modified = now_unix(); + } + + pub fn restore(&mut self) { + self.trashed_at = None; + self.modified = now_unix(); + } + + pub fn is_trashed(&self) -> bool { + self.trashed_at.is_some() + } +} + +/// Serialize a FieldValue to the string form stored in field_history. +fn serialize_history_value(value: &FieldValue) -> Result> { + let s = match value { + FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()), + FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()), + FieldValue::Totp(cfg) => { + // Store the base32-encoded secret string for human-recognizability. + let s = base32_encode(&cfg.secret); + Zeroizing::new(s) + } + _ => return Err(IdfotoError::Format("not a history-tracked kind".into())), + }; + Ok(s) +} + +/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization. +fn base32_encode(bytes: &[u8]) -> String { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::new(); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for &b in bytes { + buffer = (buffer << 8) | (b as u32); + bits += 8; + while bits >= 5 { + let idx = ((buffer >> (bits - 5)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + bits -= 5; + } + } + if bits > 0 { + let idx = ((buffer << (5 - bits)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -182,4 +322,100 @@ mod tests { assert_eq!(parsed.name.as_deref(), Some("Recovery codes")); assert_eq!(parsed.fields.len(), 2); } + + #[test] + fn new_item_has_timestamps_and_id() { + let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default()); + let item = Item::new("note".into(), core); + assert_eq!(item.id.0.len(), 16); + assert_eq!(item.r#type, ItemType::SecureNote); + assert!(item.created > 0); + assert_eq!(item.created, item.modified); + assert!(item.field_history.is_empty()); + } + + #[test] + fn soft_delete_and_restore_round_trip() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + assert!(!item.is_trashed()); + item.soft_delete(); + assert!(item.is_trashed()); + item.restore(); + assert!(!item.is_trashed()); + } + + #[test] + fn set_field_value_captures_history_for_password() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into()))); + let pw_id = pw_field.id.clone(); + item.sections.push(Section { name: None, fields: vec![pw_field] }); + + item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap(); + let hist = item.field_history.get(&pw_id).expect("history should exist"); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].value.as_str(), "old"); + } + + #[test] + fn set_field_value_does_not_capture_history_for_text() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let f = Field::new("nickname".into(), FieldValue::Text("a".into())); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap(); + assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty())); + } + + #[test] + fn set_field_value_rejects_kind_change() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let f = Field::new("x".into(), FieldValue::Text("a".into())); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into()))); + assert!(err.is_err()); + } + + #[test] + fn item_serializes_with_minimal_optional_fields() { + let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default()); + let item = Item::new("note".into(), core); + let json = serde_json::to_string(&item).unwrap(); + // No "trashed_at" or "group" or "notes" should appear when None + assert!(!json.contains("trashed_at")); + assert!(!json.contains("\"group\"")); + } + + #[test] + fn full_item_round_trip() { + let core = ItemCore::Login(crate::item_types::LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com").unwrap()), + totp: None, + }); + let mut item = Item::new("GitHub".into(), core); + item.tags = vec!["work".into()]; + item.favorite = true; + item.notes = Some("notes".into()); + + let json = serde_json::to_string(&item).unwrap(); + let parsed: Item = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.title, "GitHub"); + assert_eq!(parsed.tags, vec!["work".to_string()]); + assert!(parsed.favorite); + match parsed.core { + ItemCore::Login(l) => { + assert_eq!(l.username.as_deref(), Some("alice")); + } + other => panic!("expected Login, got {:?}", other), + } + } } diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index bfed8db..f4dd7b7 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -45,7 +45,7 @@ pub mod item_types; pub use item_types::{ItemCore, ItemType}; pub mod item; -pub use item::{Field, FieldKind, FieldValue, Section}; +pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; pub mod attachment; pub use attachment::{AttachmentRef, AttachmentSummary}; From 1a30c4ffe03ce4e56de8229277c5470c0a9242be Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 14:27:40 -0400 Subject: [PATCH 22/69] feat(core): add typed-item Manifest with schema_version 2 ManifestEntry holds the per-item browse summary including derived icon_hint (Login URL hostname) and attachment_summaries. Search matches title or tag substring case-insensitively. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/lib.rs | 5 +- crates/idfoto-core/src/manifest.rs | 159 +++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 crates/idfoto-core/src/manifest.rs diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index f4dd7b7..9cbb180 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -50,11 +50,14 @@ pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; pub mod attachment; pub use attachment::{AttachmentRef, AttachmentSummary}; +pub mod manifest; +pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; pub mod entry; -pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry}; +pub use entry::{generate_entry_id, Entry}; pub mod vault; pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; diff --git a/crates/idfoto-core/src/manifest.rs b/crates/idfoto-core/src/manifest.rs new file mode 100644 index 0000000..e029f33 --- /dev/null +++ b/crates/idfoto-core/src/manifest.rs @@ -0,0 +1,159 @@ +//! New typed-item manifest. Lives next to the old entry.rs Manifest +//! during this rewrite; entry.rs is deleted in Task 25. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::attachment::AttachmentSummary; +use crate::ids::ItemId; +use crate::item::Item; +use crate::item_types::ItemType; + +pub const MANIFEST_SCHEMA_VERSION: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub schema_version: u32, + pub items: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + pub id: ItemId, + pub r#type: ItemType, + pub title: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_hint: Option, + pub modified: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub trashed_at: Option, + #[serde(default)] + pub attachment_summaries: Vec, +} + +impl Manifest { + pub fn new() -> Self { + Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() } + } + + pub fn upsert(&mut self, item: &Item) { + let entry = ManifestEntry::from_item(item); + self.items.insert(item.id.clone(), entry); + } + + pub fn remove(&mut self, id: &ItemId) -> Option { + self.items.remove(id) + } + + pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> { + self.items.get(id) + } + + /// Case-insensitive substring match on title and tags. + pub fn search(&self, query: &str) -> Vec<&ManifestEntry> { + let q = query.to_lowercase(); + self.items + .values() + .filter(|e| { + e.title.to_lowercase().contains(&q) + || e.tags.iter().any(|t| t.to_lowercase().contains(&q)) + }) + .collect() + } +} + +impl Default for Manifest { + fn default() -> Self { Self::new() } +} + +impl ManifestEntry { + pub fn from_item(item: &Item) -> Self { + Self { + id: item.id.clone(), + r#type: item.r#type, + title: item.title.clone(), + tags: item.tags.clone(), + favorite: item.favorite, + group: item.group.clone(), + icon_hint: derive_icon_hint(item), + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.iter().map(Into::into).collect(), + } + } +} + +/// Derive an icon hint string from an item — for Login items, this is the URL hostname. +fn derive_icon_hint(item: &Item) -> Option { + use crate::item_types::ItemCore; + match &item.core { + ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::item_types::{ItemCore, LoginCore, SecureNoteCore}; + + #[test] + fn empty_manifest_has_schema_v2() { + let m = Manifest::new(); + assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION); + assert!(m.items.is_empty()); + } + + #[test] + fn upsert_and_search() { + let mut m = Manifest::new(); + let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default())); + item.tags = vec!["work".into()]; + m.upsert(&item); + + let results = m.search("github"); + assert_eq!(results.len(), 1); + let by_tag = m.search("work"); + assert_eq!(by_tag.len(), 1); + } + + #[test] + fn icon_hint_is_login_url_host() { + use url::Url; + let mut m = Manifest::new(); + let core = ItemCore::Login(LoginCore { + url: Some(Url::parse("https://api.github.com/login").unwrap()), + ..Default::default() + }); + let item = Item::new("X".into(), core); + m.upsert(&item); + let entry = m.items.values().next().unwrap(); + assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com")); + } + + #[test] + fn icon_hint_is_none_for_non_login() { + let mut m = Manifest::new(); + let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let entry = m.items.values().next().unwrap(); + assert!(entry.icon_hint.is_none()); + } + + #[test] + fn manifest_round_trips() { + let mut m = Manifest::new(); + let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let json = serde_json::to_string(&m).unwrap(); + let parsed: Manifest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION); + assert_eq!(parsed.items.len(), 1); + } +} From 266761232d525fb253d5e606accc9df9512dafda Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 14:30:17 -0400 Subject: [PATCH 23/69] feat(core): add VaultSettings with retention + generator + caps Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/lib.rs | 6 + crates/idfoto-core/src/settings.rs | 173 +++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 crates/idfoto-core/src/settings.rs diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 9cbb180..0e3d512 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -53,6 +53,12 @@ pub use attachment::{AttachmentRef, AttachmentSummary}; pub mod manifest; pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION}; +pub mod settings; +pub use settings::{ + AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention, + SymbolCharset, TrashRetention, VaultSettings, +}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; diff --git a/crates/idfoto-core/src/settings.rs b/crates/idfoto-core/src/settings.rs new file mode 100644 index 0000000..7557252 --- /dev/null +++ b/crates/idfoto-core/src/settings.rs @@ -0,0 +1,173 @@ +//! Vault-level settings: trash retention, history retention, generator +//! defaults, attachment caps, autofill TOFU acks. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultSettings { + pub trash_retention: TrashRetention, + pub field_history_retention: HistoryRetention, + pub generator_defaults: GeneratorRequest, + pub attachment_caps: AttachmentCaps, + /// hostname → unix-seconds first-acked + #[serde(default)] + pub autofill_origin_acks: HashMap, +} + +impl Default for VaultSettings { + fn default() -> Self { + Self { + trash_retention: TrashRetention::Days(30), + field_history_retention: HistoryRetention::Forever, + generator_defaults: GeneratorRequest::default(), + attachment_caps: AttachmentCaps::default(), + autofill_origin_acks: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum TrashRetention { + Days(u32), + Forever, +} + +impl TrashRetention { + pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool { + match self { + TrashRetention::Forever => false, + TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum HistoryRetention { + LastN(u32), + Days(u32), + Forever, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GeneratorRequest { + Bip39 { + word_count: u32, + separator: String, + capitalization: Capitalization, + }, + Random { + length: u32, + classes: CharClasses, + symbol_charset: SymbolCharset, + }, +} + +impl Default for GeneratorRequest { + fn default() -> Self { + GeneratorRequest::Random { + length: 20, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capitalization { + Lower, + Upper, + FirstOfEach, + Title, + Mixed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CharClasses { + pub lower: bool, + pub upper: bool, + pub digits: bool, + pub symbols: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SymbolCharset { + SafeOnly, + Extended, + Custom(String), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct AttachmentCaps { + pub per_attachment_max_bytes: u64, + pub per_item_max_count: u32, + pub per_vault_soft_cap_bytes: u64, + pub per_vault_hard_cap_bytes: u64, +} + +impl Default for AttachmentCaps { + fn default() -> Self { + Self { + per_attachment_max_bytes: 10 * 1024 * 1024, + per_item_max_count: 20, + per_vault_soft_cap_bytes: 100 * 1024 * 1024, + per_vault_hard_cap_bytes: 500 * 1024 * 1024, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec() { + let s = VaultSettings::default(); + assert!(matches!(s.trash_retention, TrashRetention::Days(30))); + assert!(matches!(s.field_history_retention, HistoryRetention::Forever)); + assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024); + assert_eq!(s.attachment_caps.per_item_max_count, 20); + } + + #[test] + fn trash_retention_purges_after_days() { + let r = TrashRetention::Days(30); + let now = 1_000_000_000; + let recently_trashed = now - 29 * 86_400; + let long_trashed = now - 31 * 86_400; + assert!(!r.should_purge(recently_trashed, now)); + assert!(r.should_purge(long_trashed, now)); + } + + #[test] + fn trash_retention_forever_never_purges() { + let r = TrashRetention::Forever; + assert!(!r.should_purge(0, 1_000_000_000)); + } + + #[test] + fn settings_round_trip() { + let s = VaultSettings::default(); + let json = serde_json::to_string(&s).unwrap(); + let parsed: VaultSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.attachment_caps.per_attachment_max_bytes, + s.attachment_caps.per_attachment_max_bytes); + } + + #[test] + fn random_generator_default_is_20_safe() { + match VaultSettings::default().generator_defaults { + GeneratorRequest::Random { length, classes, symbol_charset } => { + assert_eq!(length, 20); + assert!(classes.lower && classes.upper && classes.digits && classes.symbols); + assert!(matches!(symbol_charset, SymbolCharset::SafeOnly)); + } + _ => panic!("expected Random default"), + } + } +} From b2d8a759ef4e0a58d879807e0bd2ee01a4163d11 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:01:09 -0400 Subject: [PATCH 24/69] fix(core): SymbolCharset needs content="value" for Custom(String) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same latent bug as TrashRetention/HistoryRetention — serde's internally-tagged repr cannot merge a newtype primitive payload into a tag object. Add regression test for Custom round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/settings.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/settings.rs b/crates/idfoto-core/src/settings.rs index 7557252..ccc8c96 100644 --- a/crates/idfoto-core/src/settings.rs +++ b/crates/idfoto-core/src/settings.rs @@ -95,7 +95,7 @@ pub struct CharClasses { } #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] pub enum SymbolCharset { SafeOnly, Extended, @@ -170,4 +170,15 @@ mod tests { _ => panic!("expected Random default"), } } + + #[test] + fn symbol_charset_custom_round_trips() { + let c = SymbolCharset::Custom("!@#".into()); + let json = serde_json::to_string(&c).unwrap(); + let parsed: SymbolCharset = serde_json::from_str(&json).unwrap(); + match parsed { + SymbolCharset::Custom(s) => assert_eq!(s, "!@#"), + other => panic!("expected Custom, got {:?}", other), + } + } } From db3f2e15f2220085ed04262cecb391e82325216f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:02:39 -0400 Subject: [PATCH 25/69] feat(core): add CSPRNG random password generator with safe charset Uses rand::distributions::Uniform for unbiased sampling (audit H6). Safe symbols = !@#$%^&*-_=+ (excludes characters that web forms commonly reject). Test length capped at 128 (validator upper bound). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/generators.rs | 113 +++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 3 + 2 files changed, 116 insertions(+) create mode 100644 crates/idfoto-core/src/generators.rs diff --git a/crates/idfoto-core/src/generators.rs b/crates/idfoto-core/src/generators.rs new file mode 100644 index 0000000..b40e230 --- /dev/null +++ b/crates/idfoto-core/src/generators.rs @@ -0,0 +1,113 @@ +//! Password and passphrase generators. CSPRNG-only; rejection-sampled to +//! eliminate modulo bias. Strength rating via zxcvbn. + +use rand::distributions::{Distribution, Uniform}; +use rand::rngs::OsRng; +use zeroize::Zeroizing; + +use crate::error::{IdfotoError, Result}; +use crate::settings::{CharClasses, GeneratorRequest, SymbolCharset}; + +const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+"; +const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?."; +const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz"; +const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS: &[u8] = b"0123456789"; + +pub fn generate_password(req: &GeneratorRequest) -> Result> { + match req { + GeneratorRequest::Random { length, classes, symbol_charset } => { + random_password(*length, classes, symbol_charset) + } + GeneratorRequest::Bip39 { .. } => Err(IdfotoError::Format( + "use generate_passphrase() for BIP39 requests".into(), + )), + } +} + +fn random_password( + length: u32, + classes: &CharClasses, + symbol_charset: &SymbolCharset, +) -> Result> { + if length == 0 || length > 128 { + return Err(IdfotoError::Format("length must be 1..=128".into())); + } + let mut charset: Vec = Vec::new(); + if classes.lower { charset.extend_from_slice(LOWER); } + if classes.upper { charset.extend_from_slice(UPPER); } + if classes.digits { charset.extend_from_slice(DIGITS); } + if classes.symbols { + let symbols: &[u8] = match symbol_charset { + SymbolCharset::SafeOnly => SAFE_SYMBOLS, + SymbolCharset::Extended => EXTENDED_SYMBOLS, + SymbolCharset::Custom(s) => s.as_bytes(), + }; + charset.extend_from_slice(symbols); + } + if charset.is_empty() { + return Err(IdfotoError::Format("at least one character class required".into())); + } + + let dist = Uniform::from(0..charset.len()); + let mut rng = OsRng; + let bytes: Vec = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect(); + Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_default_password_is_20_chars() { + let req = GeneratorRequest::default(); + let pw = generate_password(&req).unwrap(); + assert_eq!(pw.len(), 20); + } + + #[test] + fn rejects_zero_length() { + let req = GeneratorRequest::Random { + length: 0, + classes: CharClasses { lower: true, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + assert!(generate_password(&req).is_err()); + } + + #[test] + fn rejects_no_classes() { + let req = GeneratorRequest::Random { + length: 8, + classes: CharClasses { lower: false, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + assert!(generate_password(&req).is_err()); + } + + #[test] + fn lower_only_password_uses_lowercase() { + let req = GeneratorRequest::Random { + length: 100, + classes: CharClasses { lower: true, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + let pw = generate_password(&req).unwrap(); + assert!(pw.chars().all(|c| c.is_ascii_lowercase())); + } + + #[test] + fn safe_symbols_excludes_quotes_and_brackets() { + let req = GeneratorRequest::Random { + length: 128, + classes: CharClasses { lower: false, upper: false, digits: false, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + }; + let pw = generate_password(&req).unwrap(); + for c in pw.chars() { + assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'), + "safe charset must not include {c}"); + } + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 0e3d512..622f340 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -59,6 +59,9 @@ pub use settings::{ SymbolCharset, TrashRetention, VaultSettings, }; +pub mod generators; +pub use generators::generate_password; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; From 61d6fb723db5ac100375b03bf97fac1ba1ce9a76 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:04:13 -0400 Subject: [PATCH 26/69] fix(core): reject non-ASCII SymbolCharset::Custom at generate time Avoids from_utf8 panic when Custom contains multi-byte UTF-8 chars whose individual bytes are independently sampled into the output. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/generators.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/generators.rs b/crates/idfoto-core/src/generators.rs index b40e230..671583d 100644 --- a/crates/idfoto-core/src/generators.rs +++ b/crates/idfoto-core/src/generators.rs @@ -41,7 +41,14 @@ fn random_password( let symbols: &[u8] = match symbol_charset { SymbolCharset::SafeOnly => SAFE_SYMBOLS, SymbolCharset::Extended => EXTENDED_SYMBOLS, - SymbolCharset::Custom(s) => s.as_bytes(), + SymbolCharset::Custom(s) => { + if !s.is_ascii() { + return Err(IdfotoError::Format( + "SymbolCharset::Custom must be ASCII-only".into(), + )); + } + s.as_bytes() + } }; charset.extend_from_slice(symbols); } @@ -110,4 +117,15 @@ mod tests { "safe charset must not include {c}"); } } + + #[test] + fn custom_charset_rejects_non_ascii() { + let req = GeneratorRequest::Random { + length: 8, + classes: CharClasses { lower: false, upper: false, digits: false, symbols: true }, + symbol_charset: SymbolCharset::Custom("ñé".into()), + }; + let err = generate_password(&req); + assert!(err.is_err(), "non-ASCII custom charset must be rejected"); + } } From 61b1a9710ba8842aec0e934c260fa06909f4666c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:31:50 -0400 Subject: [PATCH 27/69] feat(core): add BIP39 passphrase generator + zxcvbn strength gate generate_passphrase honors word_count (3-12), separator, capitalization. validate_passphrase_strength enforces zxcvbn score >= 3 (audit H3). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/generators.rs | 139 ++++++++++++++++++++++++++- crates/idfoto-core/src/lib.rs | 2 +- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/crates/idfoto-core/src/generators.rs b/crates/idfoto-core/src/generators.rs index 671583d..d8e941c 100644 --- a/crates/idfoto-core/src/generators.rs +++ b/crates/idfoto-core/src/generators.rs @@ -1,12 +1,14 @@ //! Password and passphrase generators. CSPRNG-only; rejection-sampled to //! eliminate modulo bias. Strength rating via zxcvbn. +use bip39::{Language, Mnemonic}; use rand::distributions::{Distribution, Uniform}; use rand::rngs::OsRng; +use rand::RngCore; use zeroize::Zeroizing; use crate::error::{IdfotoError, Result}; -use crate::settings::{CharClasses, GeneratorRequest, SymbolCharset}; +use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset}; const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+"; const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?."; @@ -62,6 +64,141 @@ fn random_password( Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset"))) } +pub fn generate_passphrase(req: &GeneratorRequest) -> Result> { + match req { + GeneratorRequest::Bip39 { word_count, separator, capitalization } => { + bip39_passphrase(*word_count, separator, *capitalization) + } + GeneratorRequest::Random { .. } => Err(IdfotoError::Format( + "use generate_password() for Random requests".into(), + )), + } +} + +fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result> { + if !matches!(word_count, 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12) { + return Err(IdfotoError::Format("word_count must be 3..=12".into())); + } + // bip39 v2 requires entropy 128–256 bits in multiples of 32 bits (4 bytes). + // We always generate 128 bits (16 bytes) → 12 words, then take the first + // word_count words. This gives full-entropy sourcing even for short passphrases. + let mut entropy = Zeroizing::new([0u8; 16]); + OsRng.fill_bytes(entropy.as_mut_slice()); + let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice()) + .map_err(|e| IdfotoError::Format(format!("bip39: {e}")))?; + let words: Vec = m.words().take(word_count as usize).map(|w| { + match cap { + Capitalization::Lower => w.to_ascii_lowercase(), + Capitalization::Upper => w.to_ascii_uppercase(), + Capitalization::FirstOfEach | Capitalization::Title => { + let mut chars = w.chars(); + chars.next().map(|c| c.to_ascii_uppercase().to_string()) + .unwrap_or_default() + chars.as_str() + } + Capitalization::Mixed => { + w.chars().enumerate().map(|(i, c)| { + if i % 2 == 0 { c.to_ascii_uppercase() } else { c } + }).collect() + } + } + }).collect(); + Ok(Zeroizing::new(words.join(separator))) +} + +/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses. +pub struct StrengthEstimate { + pub score: u8, + pub guesses_log10: f64, +} + +pub fn rate_passphrase(p: &str) -> StrengthEstimate { + let est = zxcvbn::zxcvbn(p, &[]); + StrengthEstimate { + score: est.score().into(), + guesses_log10: est.guesses_log10(), + } +} + +/// Strength gate at vault creation (audit H3): require score >= 3. +pub fn validate_passphrase_strength(p: &str) -> Result<()> { + let est = rate_passphrase(p); + if est.score < 3 { + return Err(IdfotoError::WeakPassphrase { score: est.score }); + } + Ok(()) +} + +#[cfg(test)] +mod bip39_tests { + use super::*; + + #[test] + fn bip39_default_is_5_space_separated_words() { + let req = GeneratorRequest::Bip39 { + word_count: 5, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + assert_eq!(pw.split(' ').count(), 5); + } + + #[test] + fn bip39_dash_separator() { + let req = GeneratorRequest::Bip39 { + word_count: 4, + separator: "-".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + assert_eq!(pw.split('-').count(), 4); + assert!(!pw.contains(' ')); + } + + #[test] + fn bip39_first_of_each_capitalizes() { + let req = GeneratorRequest::Bip39 { + word_count: 5, + separator: " ".into(), + capitalization: Capitalization::FirstOfEach, + }; + let pw = generate_passphrase(&req).unwrap(); + for word in pw.split(' ') { + let first = word.chars().next().unwrap(); + assert!(first.is_ascii_uppercase(), "word {word} should start uppercase"); + } + } + + #[test] + fn bip39_rejects_bad_word_count() { + let req = GeneratorRequest::Bip39 { + word_count: 2, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + assert!(generate_passphrase(&req).is_err()); + } + + #[test] + fn rate_passphrase_strong_one_passes_gate() { + // 5-word bip39 passphrase + let req = GeneratorRequest::Bip39 { + word_count: 6, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + assert!(validate_passphrase_strength(&pw).is_ok()); + } + + #[test] + fn rate_passphrase_weak_fails_gate() { + assert!(validate_passphrase_strength("password").is_err()); + assert!(validate_passphrase_strength("12345678").is_err()); + assert!(validate_passphrase_strength("hunter2").is_err()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 622f340..569055f 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -60,7 +60,7 @@ pub use settings::{ }; pub mod generators; -pub use generators::generate_password; +pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate}; pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; From 1fb0f8cc0352c7b8bf1c7277cf21d7b3e80cdade Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:35:01 -0400 Subject: [PATCH 28/69] chore(core): Debug derive on StrengthEstimate + fix stale test comment Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/generators.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/generators.rs b/crates/idfoto-core/src/generators.rs index d8e941c..706ae14 100644 --- a/crates/idfoto-core/src/generators.rs +++ b/crates/idfoto-core/src/generators.rs @@ -106,6 +106,7 @@ fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Re } /// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses. +#[derive(Debug, Clone, Copy)] pub struct StrengthEstimate { pub score: u8, pub guesses_log10: f64, @@ -181,7 +182,7 @@ mod bip39_tests { #[test] fn rate_passphrase_strong_one_passes_gate() { - // 5-word bip39 passphrase + // 6-word bip39 passphrase let req = GeneratorRequest::Bip39 { word_count: 6, separator: " ".into(), From f673b1ddee4bd020a02f054dad34c92c4224a2b6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:36:19 -0400 Subject: [PATCH 29/69] feat(core): add encrypt_attachment + decrypt_attachment AttachmentId is derived from sha256(plaintext) so identical content deduplicates naturally. Size cap enforced at encrypt time, returning IdfotoError::AttachmentTooLarge. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/attachment.rs | 90 ++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 2 +- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/attachment.rs b/crates/idfoto-core/src/attachment.rs index 8af857c..d0da491 100644 --- a/crates/idfoto-core/src/attachment.rs +++ b/crates/idfoto-core/src/attachment.rs @@ -40,6 +40,96 @@ impl From<&AttachmentRef> for AttachmentSummary { } } +use zeroize::Zeroizing; + +use crate::crypto::{decrypt, encrypt}; +use crate::error::{IdfotoError, Result}; + +/// Encrypted attachment with the AID derived from plaintext content. +pub struct EncryptedAttachment { + pub id: AttachmentId, + pub bytes: Vec, +} + +/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`. +/// +/// Returns [`IdfotoError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`, +/// before any crypto work is done. +/// +/// ## Call-site adaptation +/// +/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via +/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`). +pub fn encrypt_attachment( + plaintext: &[u8], + master_key: &Zeroizing<[u8; 32]>, + max_bytes: u64, +) -> Result { + if plaintext.len() as u64 > max_bytes { + return Err(IdfotoError::AttachmentTooLarge { + size: plaintext.len() as u64, + max: max_bytes, + }); + } + let id = AttachmentId::from_plaintext(plaintext); + let bytes = encrypt(&**master_key, plaintext)?; + Ok(EncryptedAttachment { id, bytes }) +} + +/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext +/// wrapped in [`Zeroizing`] so it is wiped on drop. +/// +/// ## Call-site adaptation +/// +/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`. +pub fn decrypt_attachment( + encrypted: &[u8], + master_key: &Zeroizing<[u8; 32]>, +) -> Result>> { + let plaintext = decrypt(&**master_key, encrypted)?; + Ok(Zeroizing::new(plaintext)) +} + +#[cfg(test)] +mod crypto_tests { + use super::*; + + fn key() -> Zeroizing<[u8; 32]> { + Zeroizing::new([0x42u8; 32]) + } + + #[test] + fn attachment_round_trip() { + let plaintext = b"the quick brown fox jumps over the lazy dog"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + let dec = decrypt_attachment(&enc.bytes, &key()).unwrap(); + assert_eq!(dec.as_slice(), plaintext); + } + + #[test] + fn attachment_id_matches_sha256() { + let plaintext = b"hello world"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext)); + } + + #[test] + fn oversize_attachment_rejected() { + let plaintext = vec![0u8; 11_000_000]; + let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024); + assert!(matches!(err, Err(IdfotoError::AttachmentTooLarge { .. }))); + } + + #[test] + fn wrong_key_fails_with_opaque_decrypt() { + let plaintext = b"x"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + let wrong = Zeroizing::new([0u8; 32]); + let err = decrypt_attachment(&enc.bytes, &wrong); + assert!(matches!(err, Err(IdfotoError::Decrypt))); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 569055f..bf5bde3 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -48,7 +48,7 @@ pub mod item; pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; pub mod attachment; -pub use attachment::{AttachmentRef, AttachmentSummary}; +pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment}; pub mod manifest; pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION}; From 4a98be0daea20d33fa1be99d2518469ba509aca8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:38:52 -0400 Subject: [PATCH 30/69] feat(core): rewrite vault.rs for typed items encrypt_item / decrypt_item / encrypt_manifest / decrypt_manifest / encrypt_settings / decrypt_settings. All plaintext flows through Zeroizing so JSON buffers are wiped on drop. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/lib.rs | 5 +- crates/idfoto-core/src/vault.rs | 186 +++++++++++--------------------- 2 files changed, 67 insertions(+), 124 deletions(-) diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index bf5bde3..d524ac7 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -69,6 +69,9 @@ pub mod entry; pub use entry::{generate_entry_id, Entry}; pub mod vault; -pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; +pub use vault::{ + decrypt_item, decrypt_manifest, decrypt_settings, + encrypt_item, encrypt_manifest, encrypt_settings, +}; pub mod imgsecret; diff --git a/crates/idfoto-core/src/vault.rs b/crates/idfoto-core/src/vault.rs index 7d4debc..091c1f2 100644 --- a/crates/idfoto-core/src/vault.rs +++ b/crates/idfoto-core/src/vault.rs @@ -1,150 +1,90 @@ -//! Typed encryption/decryption wrappers for vault entries and manifests. +//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item +//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse. //! -//! This module bridges the gap between the raw bytes-in/bytes-out layer in -//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function -//! follows the same pattern: -//! -//! - **Encrypt**: serialize the struct to JSON via serde, then encrypt the JSON -//! bytes with [`crate::crypto::encrypt`]. -//! - **Decrypt**: decrypt the ciphertext with [`crate::crypto::decrypt`], then -//! deserialize the resulting JSON bytes back into the typed struct. -//! -//! ## Why a single master key -//! -//! All entries and the manifest are encrypted under the same `master_key`. This is -//! simpler than a per-entry subkey hierarchy and sufficient for family-scale vaults -//! (typically < 1000 entries). The security properties are equivalent: an attacker -//! who compromises the master key can decrypt everything regardless of whether -//! subkeys exist, and the vault's threat model already assumes the master key is -//! the single point of trust (protected by the two-factor KDF). +//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old +//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in +//! Plan 1B switches to the new helpers. -use crate::crypto; -use crate::entry::{Entry, Manifest}; +use zeroize::Zeroizing; + +use crate::crypto::{decrypt, encrypt}; use crate::error::Result; +use crate::item::Item; +use crate::manifest::Manifest; +use crate::settings::VaultSettings; -/// Serialize an [`Entry`] to JSON and encrypt it under the master key. -/// -/// The resulting bytes are written to `entries/.enc` by the CLI. -/// -/// # Errors -/// -/// - [`crate::IdfotoError::Json`] if JSON serialization fails (should not happen -/// with well-formed Entry structs). -/// - [`crate::IdfotoError::Encrypt`] if the underlying AEAD operation fails. -pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result> { - let json = serde_json::to_vec(entry)?; - crypto::encrypt(master_key, &json) +pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result> { + let json = serde_json::to_vec(item)?; + let plaintext = Zeroizing::new(json); + encrypt(&**master_key, plaintext.as_slice()) } -/// Decrypt an entry blob and deserialize it back into an [`Entry`]. -/// -/// # Errors -/// -/// - [`crate::IdfotoError::Decrypt`] if the master key is wrong or the data is -/// tampered. -/// - [`crate::IdfotoError::Format`] if the ciphertext blob has an invalid header. -/// - [`crate::IdfotoError::Json`] if the decrypted JSON is malformed. -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) +pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = Zeroizing::new(plaintext); + let item: Item = serde_json::from_slice(&plaintext)?; + Ok(item) } -/// Serialize a [`Manifest`] to JSON and encrypt it under the master key. -/// -/// The resulting bytes are written to `manifest.enc` by the CLI. -/// -/// # Errors -/// -/// Same as [`encrypt_entry`]. -pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result> { +pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result> { let json = serde_json::to_vec(manifest)?; - crypto::encrypt(master_key, &json) + let plaintext = Zeroizing::new(json); + encrypt(&**master_key, plaintext.as_slice()) } -/// Decrypt a manifest blob and deserialize it back into a [`Manifest`]. -/// -/// # Errors -/// -/// Same as [`decrypt_entry`]. -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)?; +pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = Zeroizing::new(plaintext); + let manifest: Manifest = serde_json::from_slice(&plaintext)?; Ok(manifest) } +pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result> { + let json = serde_json::to_vec(settings)?; + let plaintext = Zeroizing::new(json); + encrypt(&**master_key, plaintext.as_slice()) +} + +pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = Zeroizing::new(plaintext); + let settings: VaultSettings = serde_json::from_slice(&plaintext)?; + Ok(settings) +} + #[cfg(test)] mod tests { use super::*; - use crate::entry::ManifestEntry; + use crate::item_types::{ItemCore, SecureNoteCore}; - fn test_key_a() -> [u8; 32] { - [0x42u8; 32] - } + fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) } - fn test_key_b() -> [u8; 32] { - [0x99u8; 32] - } - - fn sample_entry() -> Entry { - Entry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - password: "secret123".to_string(), - notes: None, - totp_secret: None, - group: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - } + #[test] + fn item_round_trip() { + let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new("hello".into()), + })); + let bytes = encrypt_item(&item, &key()).unwrap(); + let decoded = decrypt_item(&bytes, &key()).unwrap(); + assert_eq!(decoded.title, "note"); } #[test] - fn entry_encrypt_decrypt_round_trip() { - let key = test_key_a(); - let entry = sample_entry(); - - let ciphertext = encrypt_entry(&key, &entry).unwrap(); - let decoded = decrypt_entry(&key, &ciphertext).unwrap(); - - assert_eq!(decoded.name, "GitHub"); - assert_eq!(decoded.password, "secret123"); - assert_eq!(decoded.username, Some("alice".to_string())); + fn manifest_round_trip() { + let mut m = Manifest::new(); + let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let bytes = encrypt_manifest(&m, &key()).unwrap(); + let decoded = decrypt_manifest(&bytes, &key()).unwrap(); + assert_eq!(decoded.items.len(), 1); } #[test] - fn manifest_encrypt_decrypt_round_trip() { - let key = test_key_a(); - let mut manifest = Manifest::new(); - manifest.add_entry( - "deadbeef".to_string(), - ManifestEntry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - ); - - let ciphertext = encrypt_manifest(&key, &manifest).unwrap(); - let decoded = decrypt_manifest(&key, &ciphertext).unwrap(); - - assert_eq!(decoded.version, 1); - assert!(decoded.entries.contains_key("deadbeef")); - assert_eq!(decoded.entries["deadbeef"].name, "GitHub"); - } - - #[test] - fn entry_wrong_key_fails() { - let key_a = test_key_a(); - let key_b = test_key_b(); - let entry = sample_entry(); - - let ciphertext = encrypt_entry(&key_a, &entry).unwrap(); - let result = decrypt_entry(&key_b, &ciphertext); - - assert!(result.is_err()); + fn settings_round_trip() { + let s = VaultSettings::default(); + let bytes = encrypt_settings(&s, &key()).unwrap(); + let decoded = decrypt_settings(&bytes, &key()).unwrap(); + assert_eq!(decoded.attachment_caps.per_attachment_max_bytes, + s.attachment_caps.per_attachment_max_bytes); } } From 2074677278a23c94e629e29bf14c25c47f610c1b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:41:13 -0400 Subject: [PATCH 31/69] feat(core): add Item::prune_history honoring retention policy Forever, LastN, and Days policies all covered. Tests verify drop order (keeps newest), days cutoff, and forever-no-op semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item.rs | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/idfoto-core/src/item.rs b/crates/idfoto-core/src/item.rs index 3237e03..2f7fbbc 100644 --- a/crates/idfoto-core/src/item.rs +++ b/crates/idfoto-core/src/item.rs @@ -215,6 +215,26 @@ impl Item { pub fn is_trashed(&self) -> bool { self.trashed_at.is_some() } + + pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) { + use crate::settings::HistoryRetention; + for history in self.field_history.values_mut() { + match retention { + HistoryRetention::Forever => {} + HistoryRetention::LastN(n) => { + let n = *n as usize; + if history.len() > n { + let drop_count = history.len() - n; + history.drain(..drop_count); + } + } + HistoryRetention::Days(d) => { + let cutoff = now - (*d as i64) * 86_400; + history.retain(|e| e.replaced_at > cutoff); + } + } + } + } } /// Serialize a FieldValue to the string form stored in field_history. @@ -418,4 +438,60 @@ mod tests { other => panic!("expected Login, got {:?}", other), } } + + #[test] + fn prune_history_keeps_last_n() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + for i in 1..=5 { + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + } + assert_eq!(item.field_history[&fid].len(), 5); + + item.prune_history(&HistoryRetention::LastN(3), 0); + assert_eq!(item.field_history[&fid].len(), 3); + // Keeps the MOST RECENT 3 + assert_eq!(item.field_history[&fid][0].value.as_str(), "v2"); + } + + #[test] + fn prune_history_drops_old_entries_by_days() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + let now = 1_000_000_000; + item.field_history.insert(fid.clone(), vec![ + FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 }, + FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 1 * 86_400 }, + ]); + + item.prune_history(&HistoryRetention::Days(30), now); + assert_eq!(item.field_history[&fid].len(), 1); + assert_eq!(item.field_history[&fid][0].value.as_str(), "recent"); + } + + #[test] + fn prune_history_forever_keeps_all() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + item.field_history.insert(FieldId::new(), vec![ + FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 }, + FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 }, + ]); + item.prune_history(&HistoryRetention::Forever, 1_000_000_000); + assert_eq!(item.field_history.values().next().unwrap().len(), 2); + } } From 950ae3d8ddd547a1adf8abcb3812a44fc4b749c5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:43:16 -0400 Subject: [PATCH 32/69] refactor(core): delete entry.rs; finalize typed-item lib.rs re-exports The old Entry/ManifestEntry/Manifest types are gone. CLI/extension references break and will be fixed by Plans 1B and 1C respectively. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/entry.rs | 335 -------------------------------- crates/idfoto-core/src/lib.rs | 36 ++-- 2 files changed, 19 insertions(+), 352 deletions(-) delete mode 100644 crates/idfoto-core/src/entry.rs diff --git a/crates/idfoto-core/src/entry.rs b/crates/idfoto-core/src/entry.rs deleted file mode 100644 index 228b0b6..0000000 --- a/crates/idfoto-core/src/entry.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! 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 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. -/// - `group`: optional group label for organizing entries (e.g. "work", "personal"). -#[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, - /// Optional group for organizing entries (e.g. "work", "personal"). - #[serde(skip_serializing_if = "Option::is_none")] - pub group: Option, - pub created_at: String, - pub updated_at: String, -} - -/// 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, - /// Optional group for organizing entries (e.g. "work", "personal"). - #[serde(skip_serializing_if = "Option::is_none")] - pub group: Option, - /// Timestamp of last modification, used for sorting and display. - pub updated_at: String, -} - -/// 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(), - version: 1, - } - } - - /// 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 - .iter() - .filter(|(_, e)| { - e.name.to_lowercase().contains(&q) - || e.url - .as_deref() - .map(|u| u.to_lowercase().contains(&q)) - .unwrap_or(false) - }) - .collect() - } -} - -impl Default for Manifest { - fn default() -> Self { - Self::new() - } -} - -/// 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(); - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn entry_serialization_round_trip() { - let entry = Entry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - password: "s3cr3t".to_string(), - notes: None, - totp_secret: None, - group: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&entry).unwrap(); - let decoded: Entry = serde_json::from_str(&json).unwrap(); - - assert_eq!(decoded.name, entry.name); - assert_eq!(decoded.url, entry.url); - assert_eq!(decoded.username, entry.username); - assert_eq!(decoded.password, entry.password); - assert_eq!(decoded.notes, entry.notes); - } - - #[test] - fn manifest_add_and_lookup() { - let mut manifest = Manifest::new(); - let me = ManifestEntry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - manifest.add_entry("abc12345".to_string(), me); - - assert!(manifest.entries.contains_key("abc12345")); - assert_eq!(manifest.entries["abc12345"].name, "GitHub"); - - let removed = manifest.remove_entry("abc12345"); - assert!(removed.is_some()); - assert!(!manifest.entries.contains_key("abc12345")); - } - - #[test] - fn manifest_serialization_round_trip() { - let mut manifest = Manifest::new(); - manifest.add_entry( - "deadbeef".to_string(), - ManifestEntry { - name: "Gmail".to_string(), - url: Some("https://mail.google.com".to_string()), - username: Some("user@gmail.com".to_string()), - group: None, - updated_at: "2024-06-01T00:00:00Z".to_string(), - }, - ); - - let json = serde_json::to_string(&manifest).unwrap(); - let decoded: Manifest = serde_json::from_str(&json).unwrap(); - - assert_eq!(decoded.version, 1); - assert!(decoded.entries.contains_key("deadbeef")); - assert_eq!(decoded.entries["deadbeef"].name, "Gmail"); - } - - #[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( - "id001".to_string(), - ManifestEntry { - name: "GitHub Account".to_string(), - url: Some("https://github.com".to_string()), - username: None, - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - ); - manifest.add_entry( - "id002".to_string(), - ManifestEntry { - name: "Work Email".to_string(), - url: Some("https://mail.example.com".to_string()), - username: None, - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - ); - - // partial name match, case-insensitive - let results = manifest.search("github"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].1.name, "GitHub Account"); - - // partial URL match - let results = manifest.search("mail.example"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].1.name, "Work Email"); - - // no match - let results = manifest.search("nonexistent"); - assert_eq!(results.len(), 0); - } - - #[test] - fn entry_deserializes_without_group_field() { - // JSON from an older vault that has no "group" key — must deserialize with group: None - let json = r#"{ - "name": "OldEntry", - "url": "https://example.com", - "username": "bob", - "password": "hunter2", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - }"#; - let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field"); - assert_eq!(entry.name, "OldEntry"); - assert_eq!(entry.group, None); - } - - #[test] - fn manifest_entry_deserializes_without_group_field() { - // JSON from an older manifest that has no "group" key — must deserialize with group: None - let json = r#"{ - "name": "OldEntry", - "url": "https://example.com", - "username": "bob", - "updated_at": "2024-01-01T00:00:00Z" - }"#; - let me: ManifestEntry = serde_json::from_str(json) - .expect("should deserialize ManifestEntry without group field"); - assert_eq!(me.name, "OldEntry"); - assert_eq!(me.group, None); - } - - #[test] - fn entry_with_group_round_trips() { - let entry = Entry { - name: "Work Laptop".to_string(), - url: None, - username: Some("alice@corp.example".to_string()), - password: "p@ssw0rd".to_string(), - notes: None, - totp_secret: None, - group: Some("work".to_string()), - created_at: "2024-03-15T00:00:00Z".to_string(), - updated_at: "2024-03-15T00:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&entry).unwrap(); - // The group field should be present in the JSON output - assert!(json.contains("\"group\""), "serialized JSON should contain group field"); - assert!(json.contains("\"work\""), "serialized JSON should contain group value"); - - let decoded: Entry = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.name, "Work Laptop"); - assert_eq!(decoded.group, Some("work".to_string())); - } -} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index d524ac7..f65b670 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -10,17 +10,22 @@ //! //! ## Modules //! -//! - [`error`] -- The unified error type ([`IdfotoError`]) used across the crate. -//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated -//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer. -//! - [`entry`] -- The vault data model: [`Entry`] (full credential), -//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry -//! index that lets you list/search without decrypting every entry). -//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON -//! before encrypting, and deserialize after decrypting. -//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a -//! 256-bit secret in a JPEG image. This is the novel component that provides the -//! second authentication factor. +//! - [`error`] — The unified error type ([`IdfotoError`]). +//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and +//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02. +//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`. +//! - [`time`] — unix-seconds + `MonthYear` for card expiries. +//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the +//! `ItemCore`/`ItemType` enums. +//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`, +//! `FieldHistoryEntry`. +//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers. +//! - [`manifest`] — Browse-without-decrypt index (schema_version 2). +//! - [`settings`] — Vault-level retention, generator defaults, attachment caps. +//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn +//! strength gate. +//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings). +//! - [`imgsecret`] — DCT-based steganography for the second auth factor. //! //! ## Crypto pipeline //! @@ -35,6 +40,9 @@ pub mod error; pub use error::{IdfotoError, Result}; +pub mod crypto; +pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; + pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; @@ -62,12 +70,6 @@ pub use settings::{ pub mod generators; pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate}; -pub mod crypto; -pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; - -pub mod entry; -pub use entry::{generate_entry_id, Entry}; - pub mod vault; pub use vault::{ decrypt_item, decrypt_manifest, decrypt_settings, From c7064183d6e2d630183454dad360ad8d00e5d0a0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:45:49 -0400 Subject: [PATCH 33/69] test(core): rewrite integration test for typed items - full_workflow_login_and_note: round-trips Login + SecureNote + Manifest + Settings - two_factor_independence: confirms image_secret + passphrase combine into the master key - field_history_persists_through_round_trip: history survives encrypt/decrypt - wrong_key_fails_with_opaque_decrypt: opaque error per audit M4 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/tests/integration.rs | 230 ++++++++++-------------- 1 file changed, 94 insertions(+), 136 deletions(-) diff --git a/crates/idfoto-core/tests/integration.rs b/crates/idfoto-core/tests/integration.rs index 992f558..5cd2d35 100644 --- a/crates/idfoto-core/tests/integration.rs +++ b/crates/idfoto-core/tests/integration.rs @@ -1,153 +1,111 @@ -use idfoto_core::{ - decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest, - generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry, -}; -use rand::RngCore; +//! End-to-end integration tests for the typed-item core. -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 -} +use idfoto_core::{ + crypto::KdfParams, + derive_master_key, encrypt_item, decrypt_item, + encrypt_manifest, decrypt_manifest, + encrypt_settings, decrypt_settings, + Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings, +}; +use idfoto_core::item_types::{LoginCore, SecureNoteCore}; +use url::Url; +use zeroize::Zeroizing; fn fast_params() -> KdfParams { - KdfParams { - argon2_m: 256, - argon2_t: 1, - argon2_p: 1, - } + KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } } #[test] -fn full_vault_workflow() { - // 1. Generate carrier JPEG - let carrier = make_test_jpeg(400, 300); +fn full_workflow_login_and_note() { + let salt = [0xAAu8; 32]; + let img = [0xBBu8; 32]; + let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap(); - // 2. Generate random image_secret and embed - let mut image_secret = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut image_secret); - let stego = idfoto_core::imgsecret::embed(&carrier, &image_secret).unwrap(); - - // 3. Extract and verify - let extracted = idfoto_core::imgsecret::extract(&stego).unwrap(); - assert_eq!(extracted, image_secret, "extracted image_secret must match embedded"); - - // 4. Derive master_key with fast params - let passphrase = b"test-passphrase-long-enough"; - let mut salt = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut salt); - let params = fast_params(); - let master_key = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); - - // 5. Create and encrypt an Entry - let entry = Entry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - password: "supersecret123!".to_string(), - notes: Some("my main account".to_string()), - totp_secret: None, - group: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - - let encrypted = encrypt_entry(&master_key, &entry).unwrap(); - - // 6. Decrypt and verify fields match - let decrypted = decrypt_entry(&master_key, &encrypted).unwrap(); - assert_eq!(decrypted.name, "GitHub"); - assert_eq!(decrypted.password, "supersecret123!"); - assert_eq!(decrypted.username, Some("alice".to_string())); - assert_eq!(decrypted.url, Some("https://github.com".to_string())); - assert_eq!(decrypted.notes, Some("my main account".to_string())); - - // 7. Wrong passphrase -> different key -> decrypt fails - let wrong_key = derive_master_key(b"wrong-passphrase-entirely", &image_secret, &salt, ¶ms).unwrap(); - assert!( - decrypt_entry(&wrong_key, &encrypted).is_err(), - "decryption with wrong passphrase must fail" - ); - - // 8. Wrong image_secret -> different key -> decrypt fails - let mut wrong_secret = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut wrong_secret); - // Make sure it's actually different - if wrong_secret == image_secret { - wrong_secret[0] ^= 0xFF; - } - let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, ¶ms).unwrap(); - assert!( - decrypt_entry(&wrong_key2, &encrypted).is_err(), - "decryption with wrong image_secret must fail" - ); - - // 9. Manifest round-trip - let entry_id = generate_entry_id(); let mut manifest = Manifest::new(); - manifest.add_entry( - entry_id.clone(), - ManifestEntry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - ); + let settings = VaultSettings::default(); - let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap(); - let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap(); + // Add a Login + let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com").unwrap()), + totp: None, + })); + manifest.upsert(&login); + let login_blob = encrypt_item(&login, &key).unwrap(); - assert_eq!(manifest_dec.version, 1); - assert!(manifest_dec.entries.contains_key(&entry_id)); - assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub"); + // Add a SecureNote + let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new("recovery codes go here".into()), + })); + manifest.upsert(¬e); + let note_blob = encrypt_item(¬e, &key).unwrap(); + + // Encrypt manifest + settings + let manifest_blob = encrypt_manifest(&manifest, &key).unwrap(); + let settings_blob = encrypt_settings(&settings, &key).unwrap(); + + // Decrypt + verify + let m = decrypt_manifest(&manifest_blob, &key).unwrap(); + assert_eq!(m.items.len(), 2); + + let l: Item = decrypt_item(&login_blob, &key).unwrap(); + let n: Item = decrypt_item(¬e_blob, &key).unwrap(); + let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap(); + + assert_eq!(l.title, "GitHub"); + assert_eq!(n.title, "recovery"); + assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024); } #[test] fn two_factor_independence() { - let mut salt = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut salt); - let params = fast_params(); + // Same passphrase, different image_secret → different keys. + let salt = [0u8; 32]; + let img_a = [0x01u8; 32]; + let img_b = [0x02u8; 32]; - let passphrase_a = b"passphrase-alpha"; - let passphrase_b = b"passphrase-bravo"; + let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap(); + let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap(); + assert_ne!(*key_a, *key_b); - let mut image_secret_a = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut image_secret_a); - let mut image_secret_b = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut image_secret_b); - // Ensure they differ - if image_secret_a == image_secret_b { - image_secret_b[0] ^= 0xFF; - } - - // 1. (passphrase_A, image_A) - let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, ¶ms).unwrap(); - - // 2. (passphrase_B, image_A) -> different from #1 - let key_ba = derive_master_key(passphrase_b, &image_secret_a, &salt, ¶ms).unwrap(); - assert_ne!(key_aa, key_ba, "different passphrase must produce different key"); - - // 3. (passphrase_A, image_B) -> different from #1 - let key_ab = derive_master_key(passphrase_a, &image_secret_b, &salt, ¶ms).unwrap(); - assert_ne!(key_aa, key_ab, "different image_secret must produce different key"); - - // 4. (passphrase_B, image_B) -> different from all above - let key_bb = derive_master_key(passphrase_b, &image_secret_b, &salt, ¶ms).unwrap(); - assert_ne!(key_bb, key_aa, "key_bb must differ from key_aa"); - assert_ne!(key_bb, key_ba, "key_bb must differ from key_ba"); - assert_ne!(key_bb, key_ab, "key_bb must differ from key_ab"); + // Different passphrase, same image_secret → different keys. + let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap(); + assert_ne!(*key_a, *key_c); +} + +#[test] +fn field_history_persists_through_round_trip() { + let salt = [0u8; 32]; + let img = [0u8; 32]; + let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap(); + + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap(); + + let blob = encrypt_item(&item, &key).unwrap(); + let decoded = decrypt_item(&blob, &key).unwrap(); + let hist = decoded.field_history.get(&fid).unwrap(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].value.as_str(), "v0"); + assert_eq!(hist[1].value.as_str(), "v1"); +} + +#[test] +fn wrong_key_fails_with_opaque_decrypt() { + use idfoto_core::IdfotoError; + + let salt = [0u8; 32]; + let img = [0u8; 32]; + let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap(); + let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap(); + + let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default())); + let blob = encrypt_item(&item, &right).unwrap(); + let err = decrypt_item(&blob, &wrong); + assert!(matches!(err, Err(IdfotoError::Decrypt))); } From 08b1735b0e6bf860b55534924397696f8b31b0ec Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:47:16 -0400 Subject: [PATCH 34/69] test(core): integration tests for attachments (round-trip, AID, caps) Also derives Debug on EncryptedAttachment (required by the test's panic arm). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/attachment.rs | 1 + crates/idfoto-core/tests/attachments.rs | 52 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 crates/idfoto-core/tests/attachments.rs diff --git a/crates/idfoto-core/src/attachment.rs b/crates/idfoto-core/src/attachment.rs index d0da491..6e6c9b2 100644 --- a/crates/idfoto-core/src/attachment.rs +++ b/crates/idfoto-core/src/attachment.rs @@ -46,6 +46,7 @@ use crate::crypto::{decrypt, encrypt}; use crate::error::{IdfotoError, Result}; /// Encrypted attachment with the AID derived from plaintext content. +#[derive(Debug)] pub struct EncryptedAttachment { pub id: AttachmentId, pub bytes: Vec, diff --git a/crates/idfoto-core/tests/attachments.rs b/crates/idfoto-core/tests/attachments.rs new file mode 100644 index 0000000..8ecf188 --- /dev/null +++ b/crates/idfoto-core/tests/attachments.rs @@ -0,0 +1,52 @@ +//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement. + +use idfoto_core::{ + AttachmentId, IdfotoError, + crypto::KdfParams, + decrypt_attachment, derive_master_key, encrypt_attachment, +}; +use zeroize::Zeroizing; + +fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } } + +fn make_key() -> Zeroizing<[u8; 32]> { + derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap() +} + +#[test] +fn attachment_round_trip_5kb() { + let plaintext: Vec = (0..5000u32).map(|i| (i & 0xff) as u8).collect(); + let key = make_key(); + let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap(); + assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext)); + + let dec = decrypt_attachment(&enc.bytes, &key).unwrap(); + assert_eq!(&*dec, &plaintext); +} + +#[test] +fn identical_plaintexts_yield_identical_aids() { + let plaintext = b"hello world"; + let key = make_key(); + let a = encrypt_attachment(plaintext, &key, 1024).unwrap(); + let b = encrypt_attachment(plaintext, &key, 1024).unwrap(); + assert_eq!(a.id, b.id); + // (Bytes will differ because nonce is random per-encryption — that's expected.) +} + +#[test] +fn cap_enforcement_at_exact_max() { + let plaintext = vec![0u8; 1024]; + let key = make_key(); + // Exactly at max — should pass + let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap(); + // One byte over — should fail + let err = encrypt_attachment(&plaintext, &key, 1023); + match err { + Err(IdfotoError::AttachmentTooLarge { size, max }) => { + assert_eq!(size, 1024); + assert_eq!(max, 1023); + } + other => panic!("expected AttachmentTooLarge, got {other:?}"), + } +} From 9cd5924109e3f145168b8017a782d30aaa9b5e7c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:49:08 -0400 Subject: [PATCH 35/69] test(core): integration tests for generators (balance, BIP39, gate) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/tests/generators.rs | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 crates/idfoto-core/tests/generators.rs diff --git a/crates/idfoto-core/tests/generators.rs b/crates/idfoto-core/tests/generators.rs new file mode 100644 index 0000000..8b2b6a7 --- /dev/null +++ b/crates/idfoto-core/tests/generators.rs @@ -0,0 +1,89 @@ +//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity, +//! zxcvbn strength gate. +//! +//! # Note on length cap +//! +//! `generate_password` enforces `length <= 128`. The task originally specified +//! `length: 10_000` in a single call, but that would error at runtime. +//! +//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with +//! `length: 128` to gather 10,240 characters total, then aggregate per-class +//! counts before asserting proportions. The ±5pp tolerance is unchanged because +//! sample size is the same (~10k chars). + +use idfoto_core::{ + Capitalization, CharClasses, GeneratorRequest, SymbolCharset, + generate_passphrase, generate_password, validate_passphrase_strength, +}; + +#[test] +fn random_password_class_balance_is_reasonable() { + // Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics. + // (generate_password caps at length 128, so we cannot do a single 10,000-char call.) + let req = GeneratorRequest::Random { + length: 128, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + }; + + let mut lower = 0usize; + let mut upper = 0usize; + let mut digits = 0usize; + let mut total = 0usize; + + for _ in 0..80 { + let pw = generate_password(&req).unwrap(); + lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count(); + upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count(); + digits += pw.chars().filter(|c| c.is_ascii_digit()).count(); + total += pw.len(); + } + let symbols = total - lower - upper - digits; + + // Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74 + // Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2% + // Allow ±5pp slop. + let t = total as f64; + let assert_pct = |label: &str, actual: usize, expected_pct: f64| { + let pct = (actual as f64) / t * 100.0; + assert!( + (pct - expected_pct).abs() < 5.0, + "{label}: actual {pct:.1}% vs expected {expected_pct:.1}%" + ); + }; + assert_pct("lower", lower, 26.0 / 74.0 * 100.0); + assert_pct("upper", upper, 26.0 / 74.0 * 100.0); + assert_pct("digits", digits, 10.0 / 74.0 * 100.0); + assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0); +} + +#[test] +fn bip39_5_word_passphrase_passes_zxcvbn_gate() { + let req = GeneratorRequest::Bip39 { + word_count: 5, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3"); +} + +#[test] +fn common_weak_passphrases_fail_gate() { + for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] { + assert!( + validate_passphrase_strength(weak).is_err(), + "expected '{weak}' to fail gate" + ); + } +} + +#[test] +fn random_passwords_are_unique_across_calls() { + let req = GeneratorRequest::default(); + let mut seen = std::collections::HashSet::new(); + for _ in 0..1000 { + let pw = generate_password(&req).unwrap(); + assert!(seen.insert(pw.as_str().to_owned())); + } +} From 557fb95b69deab525189f69dc567e37dcf1a5f39 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:50:29 -0400 Subject: [PATCH 36/69] test(core): integration tests for format v2 invariants VERSION_BYTE = 0x02; v1 blobs rejected with UnsupportedFormatVersion; length-prefix Argon2 input distinguishes collision-engineerable pairs (audit H1 regression test). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/tests/format_v2.rs | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 crates/idfoto-core/tests/format_v2.rs diff --git a/crates/idfoto-core/tests/format_v2.rs b/crates/idfoto-core/tests/format_v2.rs new file mode 100644 index 0000000..97210a7 --- /dev/null +++ b/crates/idfoto-core/tests/format_v2.rs @@ -0,0 +1,54 @@ +//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with +//! UnsupportedFormatVersion, length-prefix construction guarantees domain +//! separation. + +use idfoto_core::{ + IdfotoError, + crypto::{KdfParams, VERSION_BYTE}, + decrypt, derive_master_key, encrypt, +}; +use zeroize::Zeroizing; + +fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } } + +#[test] +fn version_byte_is_2() { + assert_eq!(VERSION_BYTE, 0x02); +} + +#[test] +fn fresh_ciphertext_starts_with_0x02() { + let key = Zeroizing::new([0u8; 32]); + // encrypt(key: &[u8; 32], plaintext: &[u8]) + let ct = encrypt(&*key, b"hello").unwrap(); + assert_eq!(ct[0], 0x02); +} + +#[test] +fn v1_blob_is_rejected_with_unsupported_format_version() { + // v1 layout: [0x01][24 nonce bytes][16 tag bytes] + let mut blob = vec![0x01u8]; + blob.extend_from_slice(&[0u8; 24 + 16]); + let key = Zeroizing::new([0u8; 32]); + // decrypt(key: &[u8; 32], data: &[u8]) + let err = decrypt(&*key, &blob); + match err { + Err(IdfotoError::UnsupportedFormatVersion { found, expected }) => { + assert_eq!(found, 0x01); + assert_eq!(expected, 0x02); + } + other => panic!("expected UnsupportedFormatVersion, got {other:?}"), + } +} + +#[test] +fn length_prefix_distinguishes_concat_collisions() { + let salt = [0u8; 32]; + let img = [0x44u8; 32]; + let p1 = b"abc"; + let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...) + // could be made to collide. With length-prefix they cannot. + let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap(); + let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap(); + assert_ne!(*k1, *k2); +} From 3cf09faf1e62b2b627d796af3af36e212e8c6ee2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:52:03 -0400 Subject: [PATCH 37/69] test(core): field history integration (capture, prune, round-trip) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/tests/field_history.rs | 63 +++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 crates/idfoto-core/tests/field_history.rs diff --git a/crates/idfoto-core/tests/field_history.rs b/crates/idfoto-core/tests/field_history.rs new file mode 100644 index 0000000..2b2952c --- /dev/null +++ b/crates/idfoto-core/tests/field_history.rs @@ -0,0 +1,63 @@ +//! Field history end-to-end: capture on update, prune by retention policy, +//! survive encrypt/decrypt round-trip. + +use idfoto_core::{ + Field, FieldValue, HistoryRetention, Item, ItemCore, Section, + crypto::KdfParams, + derive_master_key, decrypt_item, encrypt_item, +}; +use idfoto_core::item_types::LoginCore; +use zeroize::Zeroizing; + +fn key() -> Zeroizing<[u8; 32]> { + derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap() +} + +#[test] +fn password_field_history_captured_on_update() { + let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap(); + + let hist = item.field_history.get(&fid).expect("history exists"); + assert_eq!(hist.len(), 3); + assert_eq!(hist[0].value.as_str(), "v0"); + assert_eq!(hist[2].value.as_str(), "v2"); +} + +#[test] +fn prune_last_n_keeps_most_recent() { + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + for i in 1..=10 { + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + } + item.prune_history(&HistoryRetention::LastN(3), 0); + let hist = &item.field_history[&fid]; + assert_eq!(hist.len(), 3); + // Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured) + assert!(hist.last().unwrap().value.as_str().starts_with('v')); +} + +#[test] +fn history_survives_encrypt_decrypt() { + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + + let blob = encrypt_item(&item, &key()).unwrap(); + let decoded = decrypt_item(&blob, &key()).unwrap(); + + let hist = decoded.field_history.get(&fid).expect("history survived"); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].value.as_str(), "v0"); +} From 49b78203f871761d3668005cc1191a36838fca5d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:55:32 -0400 Subject: [PATCH 38/69] chore(core): clean up Plan 1A clippy warnings Auto-deref at &Zeroizing<[u8;32]> call sites, range pattern in generators, useless String::into conversions in tests, unused Zeroizing import. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/attachment.rs | 4 ++-- crates/idfoto-core/src/generators.rs | 2 +- crates/idfoto-core/src/item.rs | 4 ++-- crates/idfoto-core/src/item_types/mod.rs | 1 - crates/idfoto-core/src/vault.rs | 12 ++++++------ crates/idfoto-core/tests/field_history.rs | 2 +- crates/idfoto-core/tests/format_v2.rs | 4 ++-- 7 files changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/idfoto-core/src/attachment.rs b/crates/idfoto-core/src/attachment.rs index 6e6c9b2..84e791d 100644 --- a/crates/idfoto-core/src/attachment.rs +++ b/crates/idfoto-core/src/attachment.rs @@ -73,7 +73,7 @@ pub fn encrypt_attachment( }); } let id = AttachmentId::from_plaintext(plaintext); - let bytes = encrypt(&**master_key, plaintext)?; + let bytes = encrypt(master_key, plaintext)?; Ok(EncryptedAttachment { id, bytes }) } @@ -87,7 +87,7 @@ pub fn decrypt_attachment( encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>, ) -> Result>> { - let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = decrypt(master_key, encrypted)?; Ok(Zeroizing::new(plaintext)) } diff --git a/crates/idfoto-core/src/generators.rs b/crates/idfoto-core/src/generators.rs index 706ae14..36b4930 100644 --- a/crates/idfoto-core/src/generators.rs +++ b/crates/idfoto-core/src/generators.rs @@ -76,7 +76,7 @@ pub fn generate_passphrase(req: &GeneratorRequest) -> Result> } fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result> { - if !matches!(word_count, 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12) { + if !matches!(word_count, 3..=12) { return Err(IdfotoError::Format("word_count must be 3..=12".into())); } // bip39 v2 requires entropy 128–256 bits in multiples of 32 bits (4 bytes). diff --git a/crates/idfoto-core/src/item.rs b/crates/idfoto-core/src/item.rs index 2f7fbbc..c29140a 100644 --- a/crates/idfoto-core/src/item.rs +++ b/crates/idfoto-core/src/item.rs @@ -450,7 +450,7 @@ mod tests { item.sections.push(Section { name: None, fields: vec![f] }); for i in 1..=5 { - item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap(); } assert_eq!(item.field_history[&fid].len(), 5); @@ -473,7 +473,7 @@ mod tests { let now = 1_000_000_000; item.field_history.insert(fid.clone(), vec![ FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 }, - FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 1 * 86_400 }, + FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 }, ]); item.prune_history(&HistoryRetention::Days(30), now); diff --git a/crates/idfoto-core/src/item_types/mod.rs b/crates/idfoto-core/src/item_types/mod.rs index 6f3452e..0dd9e61 100644 --- a/crates/idfoto-core/src/item_types/mod.rs +++ b/crates/idfoto-core/src/item_types/mod.rs @@ -102,7 +102,6 @@ mod tests { #[test] fn item_core_round_trips_for_all_seven_types() { - use zeroize::Zeroizing; use crate::ids::AttachmentId; let cores = vec![ diff --git a/crates/idfoto-core/src/vault.rs b/crates/idfoto-core/src/vault.rs index 091c1f2..f51d435 100644 --- a/crates/idfoto-core/src/vault.rs +++ b/crates/idfoto-core/src/vault.rs @@ -16,11 +16,11 @@ use crate::settings::VaultSettings; pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result> { let json = serde_json::to_vec(item)?; let plaintext = Zeroizing::new(json); - encrypt(&**master_key, plaintext.as_slice()) + encrypt(master_key, plaintext.as_slice()) } pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { - let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = decrypt(master_key, encrypted)?; let plaintext = Zeroizing::new(plaintext); let item: Item = serde_json::from_slice(&plaintext)?; Ok(item) @@ -29,11 +29,11 @@ pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Resul pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result> { let json = serde_json::to_vec(manifest)?; let plaintext = Zeroizing::new(json); - encrypt(&**master_key, plaintext.as_slice()) + encrypt(master_key, plaintext.as_slice()) } pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { - let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = decrypt(master_key, encrypted)?; let plaintext = Zeroizing::new(plaintext); let manifest: Manifest = serde_json::from_slice(&plaintext)?; Ok(manifest) @@ -42,11 +42,11 @@ pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> R pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result> { let json = serde_json::to_vec(settings)?; let plaintext = Zeroizing::new(json); - encrypt(&**master_key, plaintext.as_slice()) + encrypt(master_key, plaintext.as_slice()) } pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { - let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = decrypt(master_key, encrypted)?; let plaintext = Zeroizing::new(plaintext); let settings: VaultSettings = serde_json::from_slice(&plaintext)?; Ok(settings) diff --git a/crates/idfoto-core/tests/field_history.rs b/crates/idfoto-core/tests/field_history.rs index 2b2952c..0cfeb16 100644 --- a/crates/idfoto-core/tests/field_history.rs +++ b/crates/idfoto-core/tests/field_history.rs @@ -37,7 +37,7 @@ fn prune_last_n_keeps_most_recent() { let fid = f.id.clone(); item.sections.push(Section { name: None, fields: vec![f] }); for i in 1..=10 { - item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap(); } item.prune_history(&HistoryRetention::LastN(3), 0); let hist = &item.field_history[&fid]; diff --git a/crates/idfoto-core/tests/format_v2.rs b/crates/idfoto-core/tests/format_v2.rs index 97210a7..4d94cdf 100644 --- a/crates/idfoto-core/tests/format_v2.rs +++ b/crates/idfoto-core/tests/format_v2.rs @@ -20,7 +20,7 @@ fn version_byte_is_2() { fn fresh_ciphertext_starts_with_0x02() { let key = Zeroizing::new([0u8; 32]); // encrypt(key: &[u8; 32], plaintext: &[u8]) - let ct = encrypt(&*key, b"hello").unwrap(); + let ct = encrypt(&key, b"hello").unwrap(); assert_eq!(ct[0], 0x02); } @@ -31,7 +31,7 @@ fn v1_blob_is_rejected_with_unsupported_format_version() { blob.extend_from_slice(&[0u8; 24 + 16]); let key = Zeroizing::new([0u8; 32]); // decrypt(key: &[u8; 32], data: &[u8]) - let err = decrypt(&*key, &blob); + let err = decrypt(&key, &blob); match err { Err(IdfotoError::UnsupportedFormatVersion { found, expected }) => { assert_eq!(found, 0x01); From 9c49e5e148121e531efcd6a3d76f1cade833a598 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 20:33:04 -0400 Subject: [PATCH 39/69] =?UTF-8?q?chore:=20reconcile=20Plan=201A=20branch?= =?UTF-8?q?=20with=20idfoto=E2=86=92relicario=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames crate directories and sweeps identifiers so Plan 1B can reference the post-rename names throughout. - git mv crates/idfoto-{core,cli,wasm} → crates/relicario-{core,cli,wasm} - sed sweep: idfoto_core/idfoto-core/IdfotoError/IDFOTO_IMAGE/.idfoto/ etc. - All 128 relicario-core tests pass post-sweep Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 114 +++++++++--------- Cargo.toml | 6 +- .../{idfoto-cli => relicario-cli}/Cargo.toml | 6 +- .../{idfoto-cli => relicario-cli}/src/main.rs | 40 +++--- .../Cargo.toml | 2 +- .../src/attachment.rs | 10 +- .../src/crypto.rs | 36 +++--- .../src/error.rs | 20 +-- .../src/generators.rs | 18 +-- .../src/ids.rs | 0 .../src/imgsecret.rs | 32 ++--- .../src/item.rs | 10 +- .../src/item_types/card.rs | 0 .../src/item_types/document.rs | 0 .../src/item_types/identity.rs | 0 .../src/item_types/key.rs | 0 .../src/item_types/login.rs | 0 .../src/item_types/mod.rs | 0 .../src/item_types/secure_note.rs | 0 .../src/item_types/totp.rs | 0 .../src/lib.rs | 6 +- .../src/manifest.rs | 0 .../src/settings.rs | 0 .../src/time.rs | 0 .../src/vault.rs | 0 .../tests/attachments.rs | 6 +- .../tests/field_history.rs | 4 +- .../tests/format_v2.rs | 6 +- .../tests/generators.rs | 2 +- .../tests/integration.rs | 8 +- .../Cargo.toml | 4 +- .../src/lib.rs | 14 +-- 32 files changed, 172 insertions(+), 172 deletions(-) rename crates/{idfoto-cli => relicario-cli}/Cargo.toml (81%) rename crates/{idfoto-cli => relicario-cli}/src/main.rs (95%) rename crates/{idfoto-core => relicario-core}/Cargo.toml (97%) rename crates/{idfoto-core => relicario-core}/src/attachment.rs (93%) rename crates/{idfoto-core => relicario-core}/src/crypto.rs (92%) rename crates/{idfoto-core => relicario-core}/src/error.rs (81%) rename crates/{idfoto-core => relicario-core}/src/generators.rs (93%) rename crates/{idfoto-core => relicario-core}/src/ids.rs (100%) rename crates/{idfoto-core => relicario-core}/src/imgsecret.rs (97%) rename crates/{idfoto-core => relicario-core}/src/item.rs (98%) rename crates/{idfoto-core => relicario-core}/src/item_types/card.rs (100%) rename crates/{idfoto-core => relicario-core}/src/item_types/document.rs (100%) rename crates/{idfoto-core => relicario-core}/src/item_types/identity.rs (100%) rename crates/{idfoto-core => relicario-core}/src/item_types/key.rs (100%) rename crates/{idfoto-core => relicario-core}/src/item_types/login.rs (100%) rename crates/{idfoto-core => relicario-core}/src/item_types/mod.rs (100%) rename crates/{idfoto-core => relicario-core}/src/item_types/secure_note.rs (100%) rename crates/{idfoto-core => relicario-core}/src/item_types/totp.rs (100%) rename crates/{idfoto-core => relicario-core}/src/lib.rs (95%) rename crates/{idfoto-core => relicario-core}/src/manifest.rs (100%) rename crates/{idfoto-core => relicario-core}/src/settings.rs (100%) rename crates/{idfoto-core => relicario-core}/src/time.rs (100%) rename crates/{idfoto-core => relicario-core}/src/vault.rs (100%) rename crates/{idfoto-core => relicario-core}/tests/attachments.rs (92%) rename crates/{idfoto-core => relicario-core}/tests/field_history.rs (97%) rename crates/{idfoto-core => relicario-core}/tests/format_v2.rs (93%) rename crates/{idfoto-core => relicario-core}/tests/generators.rs (99%) rename crates/{idfoto-core => relicario-core}/tests/integration.rs (95%) rename crates/{idfoto-wasm => relicario-wasm}/Cargo.toml (85%) rename crates/{idfoto-wasm => relicario-wasm}/src/lib.rs (97%) diff --git a/Cargo.lock b/Cargo.lock index 3d95328..68b9fa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,63 +830,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "idfoto-cli" -version = "0.1.0" -dependencies = [ - "anyhow", - "arboard", - "clap", - "dirs", - "ed25519-dalek", - "hex", - "idfoto-core", - "rand", - "rpassword", - "serde", - "serde_json", - "zeroize", -] - -[[package]] -name = "idfoto-core" -version = "0.1.0" -dependencies = [ - "argon2", - "bip39", - "chacha20poly1305", - "chrono", - "ed25519-dalek", - "getrandom", - "hex", - "image", - "rand", - "serde", - "serde_json", - "sha2", - "thiserror 2.0.18", - "unicode-normalization", - "url", - "zeroize", - "zxcvbn", -] - -[[package]] -name = "idfoto-wasm" -version = "0.1.0" -dependencies = [ - "data-encoding", - "getrandom", - "hmac", - "idfoto-core", - "image", - "js-sys", - "serde_json", - "sha1", - "wasm-bindgen", - "wasm-bindgen-test", -] - [[package]] name = "idna" version = "1.1.0" @@ -1397,6 +1340,63 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relicario-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "clap", + "dirs", + "ed25519-dalek", + "hex", + "rand", + "relicario-core", + "rpassword", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "relicario-core" +version = "0.1.0" +dependencies = [ + "argon2", + "bip39", + "chacha20poly1305", + "chrono", + "ed25519-dalek", + "getrandom", + "hex", + "image", + "rand", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "unicode-normalization", + "url", + "zeroize", + "zxcvbn", +] + +[[package]] +name = "relicario-wasm" +version = "0.1.0" +dependencies = [ + "data-encoding", + "getrandom", + "hmac", + "image", + "js-sys", + "relicario-core", + "serde_json", + "sha1", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "rpassword" version = "5.0.1" diff --git a/Cargo.toml b/Cargo.toml index 1b9dee3..801ab1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" members = [ - "crates/idfoto-core", - "crates/idfoto-cli", - "crates/idfoto-wasm", + "crates/relicario-core", + "crates/relicario-cli", + "crates/relicario-wasm", ] diff --git a/crates/idfoto-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml similarity index 81% rename from crates/idfoto-cli/Cargo.toml rename to crates/relicario-cli/Cargo.toml index dd1b7ed..abc59a7 100644 --- a/crates/idfoto-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "idfoto-cli" +name = "relicario-cli" version = "0.1.0" edition = "2021" description = "CLI for idfoto password manager" [[bin]] -name = "idfoto" +name = "relicario" path = "src/main.rs" [dependencies] -idfoto-core = { path = "../idfoto-core" } +relicario-core = { path = "../relicario-core" } clap = { version = "4", features = ["derive"] } anyhow = "1" rpassword = "5" diff --git a/crates/idfoto-cli/src/main.rs b/crates/relicario-cli/src/main.rs similarity index 95% rename from crates/idfoto-cli/src/main.rs rename to crates/relicario-cli/src/main.rs index 8a27d5b..d720710 100644 --- a/crates/idfoto-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1,14 +1,14 @@ //! 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 +//! [`relicario_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/ +//! .relicario/ //! salt # 32-byte random salt for Argon2id KDF //! params.json # KDF tuning parameters (m, t, p) //! devices.json # registered device public keys @@ -23,10 +23,10 @@ //! //! Every command that accesses vault data follows this sequence: //! -//! 1. Locate the reference image (via `IDFOTO_IMAGE` env var or interactive prompt). +//! 1. Locate the reference image (via `RELICARIO_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/`. +//! 4. Read the vault salt and KDF params from `.relicario/`. //! 5. Derive the master key: `Argon2id(passphrase || image_secret, salt, params)`. //! 6. Use the master key to decrypt the manifest and/or individual entries. //! @@ -39,7 +39,7 @@ use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; -use idfoto_core::{ +use relicario_core::{ decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry, }; @@ -57,7 +57,7 @@ use std::process::Command; /// Top-level CLI argument parser. #[derive(Parser)] #[command( - name = "idfoto", + name = "relicario", version, about = "Git-backed password manager with reference image authentication" )] @@ -133,7 +133,7 @@ enum DeviceCommands { // ─── Device entry ─────────────────────────────────────────────────────────── -/// A registered device, stored in `.idfoto/devices.json`. +/// A registered device, stored in `.relicario/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 @@ -155,12 +155,12 @@ 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. +/// Returns the path to the `.relicario/` configuration directory within the vault. fn idfoto_dir() -> PathBuf { vault_dir().join(".idfoto") } -/// Read the 32-byte vault salt from `.idfoto/salt`. +/// Read the 32-byte vault salt from `.relicario/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 @@ -175,7 +175,7 @@ fn read_salt() -> Result<[u8; 32]> { Ok(salt) } -/// Read the KDF parameters from `.idfoto/params.json`. +/// Read the KDF parameters from `.relicario/params.json`. fn read_params() -> Result { let data = fs::read_to_string(idfoto_dir().join("params.json")) .context("failed to read params.json")?; @@ -185,10 +185,10 @@ fn read_params() -> Result { /// Locate the reference image path. /// -/// First checks the `IDFOTO_IMAGE` environment variable (useful for scripting +/// First checks the `RELICARIO_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") { + if let Ok(path) = std::env::var("RELICARIO_IMAGE") { return Ok(PathBuf::from(path)); } let path = prompt("Reference image path")?; @@ -207,12 +207,12 @@ fn unlock(image_path: &PathBuf) -> Result> { let jpeg_data = fs::read(image_path).context("failed to read reference image")?; let image_secret = - idfoto_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?; + relicario_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?; let salt = read_salt()?; let params = read_params()?; - let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + let master_key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) .context("failed to derive master key")?; Ok(master_key) @@ -329,7 +329,7 @@ fn generate_password(length: usize) -> String { /// 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/). +/// 8. Create the vault directory structure (.relicario/, 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<()> { @@ -342,7 +342,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { // 3. Embed secret into carrier let reference_jpeg = - idfoto_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?; + relicario_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?; // 4. Save reference JPEG fs::write(&output, &reference_jpeg).context("failed to write reference image")?; @@ -371,7 +371,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { // 7. Derive master key let params = KdfParams::default(); - let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + let master_key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) .context("failed to derive master key")?; // 8. Create directory structure @@ -758,7 +758,7 @@ fn cmd_sync() -> Result<()> { // ─── Device management ────────────────────────────────────────────────────── -/// Read the device registry from `.idfoto/devices.json`. +/// Read the device registry from `.relicario/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")?; @@ -766,7 +766,7 @@ fn read_devices() -> Result> { Ok(devices) } -/// Write the device registry to `.idfoto/devices.json`. +/// Write the device registry to `.relicario/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")?; @@ -801,7 +801,7 @@ fn cmd_device_add(name: String) -> Result<()> { // 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"); + .join("relicario"); fs::create_dir_all(&config_dir).context("failed to create config directory")?; let key_path = config_dir.join(format!("{}.key", name)); fs::write(&key_path, &private_key_hex).context("failed to write private key")?; diff --git a/crates/idfoto-core/Cargo.toml b/crates/relicario-core/Cargo.toml similarity index 97% rename from crates/idfoto-core/Cargo.toml rename to crates/relicario-core/Cargo.toml index 0ae3a70..0e3b3c7 100644 --- a/crates/idfoto-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "idfoto-core" +name = "relicario-core" version = "0.1.0" edition = "2021" description = "Core library for idfoto password manager" diff --git a/crates/idfoto-core/src/attachment.rs b/crates/relicario-core/src/attachment.rs similarity index 93% rename from crates/idfoto-core/src/attachment.rs rename to crates/relicario-core/src/attachment.rs index 84e791d..93fb7cc 100644 --- a/crates/idfoto-core/src/attachment.rs +++ b/crates/relicario-core/src/attachment.rs @@ -43,7 +43,7 @@ impl From<&AttachmentRef> for AttachmentSummary { use zeroize::Zeroizing; use crate::crypto::{decrypt, encrypt}; -use crate::error::{IdfotoError, Result}; +use crate::error::{RelicarioError, Result}; /// Encrypted attachment with the AID derived from plaintext content. #[derive(Debug)] @@ -54,7 +54,7 @@ pub struct EncryptedAttachment { /// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`. /// -/// Returns [`IdfotoError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`, +/// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`, /// before any crypto work is done. /// /// ## Call-site adaptation @@ -67,7 +67,7 @@ pub fn encrypt_attachment( max_bytes: u64, ) -> Result { if plaintext.len() as u64 > max_bytes { - return Err(IdfotoError::AttachmentTooLarge { + return Err(RelicarioError::AttachmentTooLarge { size: plaintext.len() as u64, max: max_bytes, }); @@ -118,7 +118,7 @@ mod crypto_tests { fn oversize_attachment_rejected() { let plaintext = vec![0u8; 11_000_000]; let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024); - assert!(matches!(err, Err(IdfotoError::AttachmentTooLarge { .. }))); + assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. }))); } #[test] @@ -127,7 +127,7 @@ mod crypto_tests { let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); let wrong = Zeroizing::new([0u8; 32]); let err = decrypt_attachment(&enc.bytes, &wrong); - assert!(matches!(err, Err(IdfotoError::Decrypt))); + assert!(matches!(err, Err(RelicarioError::Decrypt))); } } diff --git a/crates/idfoto-core/src/crypto.rs b/crates/relicario-core/src/crypto.rs similarity index 92% rename from crates/idfoto-core/src/crypto.rs rename to crates/relicario-core/src/crypto.rs index e2d7fa8..8c38d78 100644 --- a/crates/idfoto-core/src/crypto.rs +++ b/crates/relicario-core/src/crypto.rs @@ -41,7 +41,7 @@ //! ``` //! //! Both factors contribute to the derived key -- compromising one without the -//! other is insufficient. The salt is vault-specific and stored in `.idfoto/salt`. +//! other is insufficient. The salt is vault-specific and stored in `.relicario/salt`. use argon2::{Algorithm, Argon2, Params, Version}; use chacha20poly1305::{ @@ -53,7 +53,7 @@ use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; use zeroize::Zeroizing; -use crate::error::{IdfotoError, Result}; +use crate::error::{RelicarioError, Result}; /// Current binary format version. Increment this if the ciphertext layout changes. pub const VERSION_BYTE: u8 = 0x02; @@ -76,7 +76,7 @@ const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce /// /// # Errors /// -/// Returns [`IdfotoError::Encrypt`] if the underlying AEAD operation fails +/// Returns [`RelicarioError::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()); @@ -90,7 +90,7 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { let ciphertext = cipher .encrypt(&nonce, plaintext) - .map_err(|e| IdfotoError::Encrypt(e.to_string()))?; + .map_err(|e| RelicarioError::Encrypt(e.to_string()))?; // Output: version(1) || nonce(24) || ciphertext+tag let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len()); @@ -105,27 +105,27 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { /// /// 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`] +/// tampered with, the Poly1305 tag verification fails and [`RelicarioError::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 +/// - [`RelicarioError::Format`] if the data is too short or has an unknown version byte. +/// - [`RelicarioError::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( + return Err(RelicarioError::Format( "data too short to be valid ciphertext".into(), )); } let found = data[0]; if found != VERSION_BYTE { - return Err(IdfotoError::UnsupportedFormatVersion { + return Err(RelicarioError::UnsupportedFormatVersion { found, expected: VERSION_BYTE, }); @@ -137,14 +137,14 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result> { let cipher = XChaCha20Poly1305::new(key.into()); let plaintext = cipher .decrypt(nonce, ciphertext) - .map_err(|_| IdfotoError::Decrypt)?; + .map_err(|_| RelicarioError::Decrypt)?; Ok(plaintext) } /// Tunable parameters for the Argon2id key derivation function. /// -/// These are stored in the vault's `.idfoto/params.json` so that every client +/// These are stored in the vault's `.relicario/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). @@ -193,8 +193,8 @@ impl Default for KdfParams { /// - `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`). +/// - `salt`: a 32-byte vault-specific salt (stored in `.relicario/salt`). +/// - `params`: the Argon2id tuning parameters (stored in `.relicario/params.json`). /// /// # Returns /// @@ -202,7 +202,7 @@ impl Default for KdfParams { /// /// # Errors /// -/// Returns [`IdfotoError::Kdf`] if the Argon2id parameters are invalid (e.g., +/// Returns [`RelicarioError::Kdf`] if the Argon2id parameters are invalid (e.g., /// memory cost below the library's minimum). pub fn derive_master_key( passphrase: &[u8], @@ -216,7 +216,7 @@ pub fn derive_master_key( params.argon2_p, Some(32), ) - .map_err(|e| IdfotoError::Kdf(e.to_string()))?; + .map_err(|e| RelicarioError::Kdf(e.to_string()))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); @@ -238,7 +238,7 @@ pub fn derive_master_key( let mut output = Zeroizing::new([0u8; 32]); argon2 .hash_password_into(password.as_slice(), salt, output.as_mut()) - .map_err(|e| IdfotoError::Kdf(e.to_string()))?; + .map_err(|e| RelicarioError::Kdf(e.to_string()))?; Ok(output) } @@ -316,7 +316,7 @@ mod tests { let result = decrypt(&wrong_key, &ciphertext); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), IdfotoError::Decrypt)); + assert!(matches!(result.unwrap_err(), RelicarioError::Decrypt)); } #[test] @@ -410,7 +410,7 @@ mod tests { let key = Zeroizing::new([0u8; 32]); let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt"); match err { - IdfotoError::UnsupportedFormatVersion { found, expected } => { + RelicarioError::UnsupportedFormatVersion { found, expected } => { assert_eq!(found, 0x01); assert_eq!(expected, 0x02); } diff --git a/crates/idfoto-core/src/error.rs b/crates/relicario-core/src/error.rs similarity index 81% rename from crates/idfoto-core/src/error.rs rename to crates/relicario-core/src/error.rs index cd9be18..1bf9cbd 100644 --- a/crates/idfoto-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -1,19 +1,19 @@ -//! Unified error type for the idfoto-core crate. +//! Unified error type for the relicario-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 +//! 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. +/// All errors that can originate from relicario-core operations. /// /// Variants are ordered roughly by the pipeline stage where they occur: /// KDF -> encryption -> decryption -> format parsing -> item lookup -> image /// steganography -> serialization -> device keys. #[derive(Debug, Error)] -pub enum IdfotoError { +pub enum RelicarioError { #[error("key derivation failed: {0}")] Kdf(String), @@ -64,7 +64,7 @@ pub enum IdfotoError { } /// Crate-wide result alias, reducing boilerplate in function signatures. -pub type Result = std::result::Result; +pub type Result = std::result::Result; #[cfg(test)] mod tests { @@ -72,13 +72,13 @@ mod tests { #[test] fn decrypt_error_message_is_opaque() { - let err = IdfotoError::Decrypt; + let err = RelicarioError::Decrypt; assert_eq!(format!("{}", err), "decryption failed"); } #[test] fn weak_passphrase_carries_score() { - let err = IdfotoError::WeakPassphrase { score: 1 }; + let err = RelicarioError::WeakPassphrase { score: 1 }; let s = format!("{}", err); assert!(s.contains("passphrase")); assert!(s.contains("strength")); @@ -86,7 +86,7 @@ mod tests { #[test] fn attachment_too_large_reports_sizes() { - let err = IdfotoError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 }; + let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 }; let s = format!("{}", err); assert!(s.contains("11000000")); assert!(s.contains("10485760")); @@ -94,13 +94,13 @@ mod tests { #[test] fn item_not_found_carries_id() { - let err = IdfotoError::ItemNotFound("abc123".to_string()); + let err = RelicarioError::ItemNotFound("abc123".to_string()); assert!(format!("{}", err).contains("abc123")); } #[test] fn unsupported_format_version_reports_byte() { - let err = IdfotoError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }; + let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }; let s = format!("{}", err); assert!(s.contains("01") || s.contains("1")); assert!(s.contains("02") || s.contains("2")); diff --git a/crates/idfoto-core/src/generators.rs b/crates/relicario-core/src/generators.rs similarity index 93% rename from crates/idfoto-core/src/generators.rs rename to crates/relicario-core/src/generators.rs index 36b4930..c54f976 100644 --- a/crates/idfoto-core/src/generators.rs +++ b/crates/relicario-core/src/generators.rs @@ -7,7 +7,7 @@ use rand::rngs::OsRng; use rand::RngCore; use zeroize::Zeroizing; -use crate::error::{IdfotoError, Result}; +use crate::error::{RelicarioError, Result}; use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset}; const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+"; @@ -21,7 +21,7 @@ pub fn generate_password(req: &GeneratorRequest) -> Result> { GeneratorRequest::Random { length, classes, symbol_charset } => { random_password(*length, classes, symbol_charset) } - GeneratorRequest::Bip39 { .. } => Err(IdfotoError::Format( + GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format( "use generate_passphrase() for BIP39 requests".into(), )), } @@ -33,7 +33,7 @@ fn random_password( symbol_charset: &SymbolCharset, ) -> Result> { if length == 0 || length > 128 { - return Err(IdfotoError::Format("length must be 1..=128".into())); + return Err(RelicarioError::Format("length must be 1..=128".into())); } let mut charset: Vec = Vec::new(); if classes.lower { charset.extend_from_slice(LOWER); } @@ -45,7 +45,7 @@ fn random_password( SymbolCharset::Extended => EXTENDED_SYMBOLS, SymbolCharset::Custom(s) => { if !s.is_ascii() { - return Err(IdfotoError::Format( + return Err(RelicarioError::Format( "SymbolCharset::Custom must be ASCII-only".into(), )); } @@ -55,7 +55,7 @@ fn random_password( charset.extend_from_slice(symbols); } if charset.is_empty() { - return Err(IdfotoError::Format("at least one character class required".into())); + return Err(RelicarioError::Format("at least one character class required".into())); } let dist = Uniform::from(0..charset.len()); @@ -69,7 +69,7 @@ pub fn generate_passphrase(req: &GeneratorRequest) -> Result> GeneratorRequest::Bip39 { word_count, separator, capitalization } => { bip39_passphrase(*word_count, separator, *capitalization) } - GeneratorRequest::Random { .. } => Err(IdfotoError::Format( + GeneratorRequest::Random { .. } => Err(RelicarioError::Format( "use generate_password() for Random requests".into(), )), } @@ -77,7 +77,7 @@ pub fn generate_passphrase(req: &GeneratorRequest) -> Result> fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result> { if !matches!(word_count, 3..=12) { - return Err(IdfotoError::Format("word_count must be 3..=12".into())); + return Err(RelicarioError::Format("word_count must be 3..=12".into())); } // bip39 v2 requires entropy 128–256 bits in multiples of 32 bits (4 bytes). // We always generate 128 bits (16 bytes) → 12 words, then take the first @@ -85,7 +85,7 @@ fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Re let mut entropy = Zeroizing::new([0u8; 16]); OsRng.fill_bytes(entropy.as_mut_slice()); let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice()) - .map_err(|e| IdfotoError::Format(format!("bip39: {e}")))?; + .map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?; let words: Vec = m.words().take(word_count as usize).map(|w| { match cap { Capitalization::Lower => w.to_ascii_lowercase(), @@ -124,7 +124,7 @@ pub fn rate_passphrase(p: &str) -> StrengthEstimate { pub fn validate_passphrase_strength(p: &str) -> Result<()> { let est = rate_passphrase(p); if est.score < 3 { - return Err(IdfotoError::WeakPassphrase { score: est.score }); + return Err(RelicarioError::WeakPassphrase { score: est.score }); } Ok(()) } diff --git a/crates/idfoto-core/src/ids.rs b/crates/relicario-core/src/ids.rs similarity index 100% rename from crates/idfoto-core/src/ids.rs rename to crates/relicario-core/src/ids.rs diff --git a/crates/idfoto-core/src/imgsecret.rs b/crates/relicario-core/src/imgsecret.rs similarity index 97% rename from crates/idfoto-core/src/imgsecret.rs rename to crates/relicario-core/src/imgsecret.rs index 1b22cc5..cfd1686 100644 --- a/crates/idfoto-core/src/imgsecret.rs +++ b/crates/relicario-core/src/imgsecret.rs @@ -39,7 +39,7 @@ //! - 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 crate::error::{RelicarioError, Result}; use image::codecs::jpeg::JpegEncoder; use image::ImageReader; use image::{ImageEncoder, Rgb, RgbImage}; @@ -179,10 +179,10 @@ struct EmbedRegion { fn extract_y_channel(jpeg_bytes: &[u8]) -> Result { let reader = ImageReader::new(Cursor::new(jpeg_bytes)) .with_guessed_format() - .map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?; + .map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?; let img = reader .decode() - .map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?; + .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); let mut data = Vec::with_capacity(width * height); @@ -527,10 +527,10 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result> { let reader = ImageReader::new(Cursor::new(original_jpeg)) .with_guessed_format() - .map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?; + .map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?; let img = reader .decode() - .map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?; + .map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?; let rgb = img.to_rgb8(); let (width, height) = (rgb.width(), rgb.height()); @@ -572,7 +572,7 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result Result Result> { let mut y = extract_y_channel(carrier_jpeg)?; if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION { - return Err(IdfotoError::ImageTooSmall { + return Err(RelicarioError::ImageTooSmall { min_width: MIN_DIMENSION, min_height: MIN_DIMENSION, actual_width: y.width as u32, @@ -616,7 +616,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> { let total_blocks = region.blocks_x * region.blocks_y; if total_blocks < BLOCKS_PER_COPY * MIN_COPIES { - return Err(IdfotoError::ImageTooSmall { + return Err(RelicarioError::ImageTooSmall { min_width: MIN_DIMENSION, min_height: MIN_DIMENSION, actual_width: y.width as u32, @@ -669,7 +669,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> { /// /// # Errors /// -/// - [`IdfotoError::ExtractionFailed`] if no valid secret could be recovered +/// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered /// (image was never watermarked, or was too heavily recompressed/cropped). pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { extract_with_crop_recovery(jpeg_bytes) @@ -695,7 +695,7 @@ fn try_extract_with_layout( ) -> Result<[u8; 32]> { let positions = compute_embed_positions(orig_w, orig_h); if positions.is_empty() { - return Err(IdfotoError::ExtractionFailed); + return Err(RelicarioError::ExtractionFailed); } let region = compute_region(orig_w, orig_h); @@ -750,14 +750,14 @@ fn try_extract_with_layout( let mut result_bits = vec![0u8; SECRET_BITS]; for i in 0..SECRET_BITS { if votes_total[i] == 0 { - return Err(IdfotoError::ExtractionFailed); + return Err(RelicarioError::ExtractionFailed); } let ones = votes_one[i]; let zeros = votes_total[i] - ones; let majority = ones.max(zeros); let confidence = majority as f64 / votes_total[i] as f64; if confidence < 0.60 { - return Err(IdfotoError::ExtractionFailed); + return Err(RelicarioError::ExtractionFailed); } result_bits[i] = if ones > zeros { 1 } else { 0 }; } @@ -785,7 +785,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { let y = extract_y_channel(jpeg_bytes)?; if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION { - return Err(IdfotoError::ExtractionFailed); + return Err(RelicarioError::ExtractionFailed); } // Try 1: assume the image is uncropped (original size = current size) @@ -830,7 +830,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { } } - Err(IdfotoError::ExtractionFailed) + Err(RelicarioError::ExtractionFailed) } // ─── Tests ─────────────────────────────────────────────────────────────────── diff --git a/crates/idfoto-core/src/item.rs b/crates/relicario-core/src/item.rs similarity index 98% rename from crates/idfoto-core/src/item.rs rename to crates/relicario-core/src/item.rs index c29140a..fa65613 100644 --- a/crates/idfoto-core/src/item.rs +++ b/crates/relicario-core/src/item.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use url::Url; use zeroize::Zeroizing; -use crate::error::{IdfotoError, Result}; +use crate::error::{RelicarioError, Result}; use crate::ids::{AttachmentId, FieldId}; use crate::item_types::TotpConfig; use crate::time::MonthYear; @@ -96,7 +96,7 @@ impl Field { /// Verify kind/value discriminants match. Called after deserialization. pub fn validate(&self) -> Result<()> { if self.kind != self.value.kind() { - return Err(IdfotoError::Format(format!( + return Err(RelicarioError::Format(format!( "field {}: kind {:?} does not match value discriminant {:?}", self.id.as_str(), self.kind, @@ -182,7 +182,7 @@ impl Item { for section in &mut self.sections { if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) { if field.value.kind() != new_value.kind() { - return Err(IdfotoError::Format(format!( + return Err(RelicarioError::Format(format!( "field {}: cannot change kind from {:?} to {:?}", field.id.as_str(), field.value.kind(), new_value.kind() ))); @@ -199,7 +199,7 @@ impl Item { return Ok(()); } } - Err(IdfotoError::Format(format!("field {} not found", field_id.as_str()))) + Err(RelicarioError::Format(format!("field {} not found", field_id.as_str()))) } pub fn soft_delete(&mut self) { @@ -247,7 +247,7 @@ fn serialize_history_value(value: &FieldValue) -> Result> { let s = base32_encode(&cfg.secret); Zeroizing::new(s) } - _ => return Err(IdfotoError::Format("not a history-tracked kind".into())), + _ => return Err(RelicarioError::Format("not a history-tracked kind".into())), }; Ok(s) } diff --git a/crates/idfoto-core/src/item_types/card.rs b/crates/relicario-core/src/item_types/card.rs similarity index 100% rename from crates/idfoto-core/src/item_types/card.rs rename to crates/relicario-core/src/item_types/card.rs diff --git a/crates/idfoto-core/src/item_types/document.rs b/crates/relicario-core/src/item_types/document.rs similarity index 100% rename from crates/idfoto-core/src/item_types/document.rs rename to crates/relicario-core/src/item_types/document.rs diff --git a/crates/idfoto-core/src/item_types/identity.rs b/crates/relicario-core/src/item_types/identity.rs similarity index 100% rename from crates/idfoto-core/src/item_types/identity.rs rename to crates/relicario-core/src/item_types/identity.rs diff --git a/crates/idfoto-core/src/item_types/key.rs b/crates/relicario-core/src/item_types/key.rs similarity index 100% rename from crates/idfoto-core/src/item_types/key.rs rename to crates/relicario-core/src/item_types/key.rs diff --git a/crates/idfoto-core/src/item_types/login.rs b/crates/relicario-core/src/item_types/login.rs similarity index 100% rename from crates/idfoto-core/src/item_types/login.rs rename to crates/relicario-core/src/item_types/login.rs diff --git a/crates/idfoto-core/src/item_types/mod.rs b/crates/relicario-core/src/item_types/mod.rs similarity index 100% rename from crates/idfoto-core/src/item_types/mod.rs rename to crates/relicario-core/src/item_types/mod.rs diff --git a/crates/idfoto-core/src/item_types/secure_note.rs b/crates/relicario-core/src/item_types/secure_note.rs similarity index 100% rename from crates/idfoto-core/src/item_types/secure_note.rs rename to crates/relicario-core/src/item_types/secure_note.rs diff --git a/crates/idfoto-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs similarity index 100% rename from crates/idfoto-core/src/item_types/totp.rs rename to crates/relicario-core/src/item_types/totp.rs diff --git a/crates/idfoto-core/src/lib.rs b/crates/relicario-core/src/lib.rs similarity index 95% rename from crates/idfoto-core/src/lib.rs rename to crates/relicario-core/src/lib.rs index f65b670..634afa8 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -1,4 +1,4 @@ -//! # idfoto-core +//! # relicario-core //! //! Platform-agnostic core library for the idfoto password manager. //! @@ -10,7 +10,7 @@ //! //! ## Modules //! -//! - [`error`] — The unified error type ([`IdfotoError`]). +//! - [`error`] — The unified error type ([`RelicarioError`]). //! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and //! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02. //! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`. @@ -38,7 +38,7 @@ //! ``` pub mod error; -pub use error::{IdfotoError, Result}; +pub use error::{RelicarioError, Result}; pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; diff --git a/crates/idfoto-core/src/manifest.rs b/crates/relicario-core/src/manifest.rs similarity index 100% rename from crates/idfoto-core/src/manifest.rs rename to crates/relicario-core/src/manifest.rs diff --git a/crates/idfoto-core/src/settings.rs b/crates/relicario-core/src/settings.rs similarity index 100% rename from crates/idfoto-core/src/settings.rs rename to crates/relicario-core/src/settings.rs diff --git a/crates/idfoto-core/src/time.rs b/crates/relicario-core/src/time.rs similarity index 100% rename from crates/idfoto-core/src/time.rs rename to crates/relicario-core/src/time.rs diff --git a/crates/idfoto-core/src/vault.rs b/crates/relicario-core/src/vault.rs similarity index 100% rename from crates/idfoto-core/src/vault.rs rename to crates/relicario-core/src/vault.rs diff --git a/crates/idfoto-core/tests/attachments.rs b/crates/relicario-core/tests/attachments.rs similarity index 92% rename from crates/idfoto-core/tests/attachments.rs rename to crates/relicario-core/tests/attachments.rs index 8ecf188..300228b 100644 --- a/crates/idfoto-core/tests/attachments.rs +++ b/crates/relicario-core/tests/attachments.rs @@ -1,7 +1,7 @@ //! Attachment encrypt/decrypt + content-addressed AID + cap enforcement. -use idfoto_core::{ - AttachmentId, IdfotoError, +use relicario_core::{ + AttachmentId, RelicarioError, crypto::KdfParams, decrypt_attachment, derive_master_key, encrypt_attachment, }; @@ -43,7 +43,7 @@ fn cap_enforcement_at_exact_max() { // One byte over — should fail let err = encrypt_attachment(&plaintext, &key, 1023); match err { - Err(IdfotoError::AttachmentTooLarge { size, max }) => { + Err(RelicarioError::AttachmentTooLarge { size, max }) => { assert_eq!(size, 1024); assert_eq!(max, 1023); } diff --git a/crates/idfoto-core/tests/field_history.rs b/crates/relicario-core/tests/field_history.rs similarity index 97% rename from crates/idfoto-core/tests/field_history.rs rename to crates/relicario-core/tests/field_history.rs index 0cfeb16..0376aed 100644 --- a/crates/idfoto-core/tests/field_history.rs +++ b/crates/relicario-core/tests/field_history.rs @@ -1,12 +1,12 @@ //! Field history end-to-end: capture on update, prune by retention policy, //! survive encrypt/decrypt round-trip. -use idfoto_core::{ +use relicario_core::{ Field, FieldValue, HistoryRetention, Item, ItemCore, Section, crypto::KdfParams, derive_master_key, decrypt_item, encrypt_item, }; -use idfoto_core::item_types::LoginCore; +use relicario_core::item_types::LoginCore; use zeroize::Zeroizing; fn key() -> Zeroizing<[u8; 32]> { diff --git a/crates/idfoto-core/tests/format_v2.rs b/crates/relicario-core/tests/format_v2.rs similarity index 93% rename from crates/idfoto-core/tests/format_v2.rs rename to crates/relicario-core/tests/format_v2.rs index 4d94cdf..b4b4a8e 100644 --- a/crates/idfoto-core/tests/format_v2.rs +++ b/crates/relicario-core/tests/format_v2.rs @@ -2,8 +2,8 @@ //! UnsupportedFormatVersion, length-prefix construction guarantees domain //! separation. -use idfoto_core::{ - IdfotoError, +use relicario_core::{ + RelicarioError, crypto::{KdfParams, VERSION_BYTE}, decrypt, derive_master_key, encrypt, }; @@ -33,7 +33,7 @@ fn v1_blob_is_rejected_with_unsupported_format_version() { // decrypt(key: &[u8; 32], data: &[u8]) let err = decrypt(&key, &blob); match err { - Err(IdfotoError::UnsupportedFormatVersion { found, expected }) => { + Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => { assert_eq!(found, 0x01); assert_eq!(expected, 0x02); } diff --git a/crates/idfoto-core/tests/generators.rs b/crates/relicario-core/tests/generators.rs similarity index 99% rename from crates/idfoto-core/tests/generators.rs rename to crates/relicario-core/tests/generators.rs index 8b2b6a7..e53b1cd 100644 --- a/crates/idfoto-core/tests/generators.rs +++ b/crates/relicario-core/tests/generators.rs @@ -11,7 +11,7 @@ //! counts before asserting proportions. The ±5pp tolerance is unchanged because //! sample size is the same (~10k chars). -use idfoto_core::{ +use relicario_core::{ Capitalization, CharClasses, GeneratorRequest, SymbolCharset, generate_passphrase, generate_password, validate_passphrase_strength, }; diff --git a/crates/idfoto-core/tests/integration.rs b/crates/relicario-core/tests/integration.rs similarity index 95% rename from crates/idfoto-core/tests/integration.rs rename to crates/relicario-core/tests/integration.rs index 5cd2d35..83419eb 100644 --- a/crates/idfoto-core/tests/integration.rs +++ b/crates/relicario-core/tests/integration.rs @@ -1,13 +1,13 @@ //! End-to-end integration tests for the typed-item core. -use idfoto_core::{ +use relicario_core::{ crypto::KdfParams, derive_master_key, encrypt_item, decrypt_item, encrypt_manifest, decrypt_manifest, encrypt_settings, decrypt_settings, Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings, }; -use idfoto_core::item_types::{LoginCore, SecureNoteCore}; +use relicario_core::item_types::{LoginCore, SecureNoteCore}; use url::Url; use zeroize::Zeroizing; @@ -97,7 +97,7 @@ fn field_history_persists_through_round_trip() { #[test] fn wrong_key_fails_with_opaque_decrypt() { - use idfoto_core::IdfotoError; + use relicario_core::RelicarioError; let salt = [0u8; 32]; let img = [0u8; 32]; @@ -107,5 +107,5 @@ fn wrong_key_fails_with_opaque_decrypt() { let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default())); let blob = encrypt_item(&item, &right).unwrap(); let err = decrypt_item(&blob, &wrong); - assert!(matches!(err, Err(IdfotoError::Decrypt))); + assert!(matches!(err, Err(RelicarioError::Decrypt))); } diff --git a/crates/idfoto-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml similarity index 85% rename from crates/idfoto-wasm/Cargo.toml rename to crates/relicario-wasm/Cargo.toml index 16c1e24..e82da03 100644 --- a/crates/idfoto-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "idfoto-wasm" +name = "relicario-wasm" version = "0.1.0" edition = "2021" description = "WASM bindings for idfoto password manager" @@ -8,7 +8,7 @@ description = "WASM bindings for idfoto password manager" crate-type = ["cdylib", "rlib"] [dependencies] -idfoto-core = { path = "../idfoto-core" } +relicario-core = { path = "../relicario-core" } wasm-bindgen = "0.2" js-sys = "0.3" serde_json = "1" diff --git a/crates/idfoto-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs similarity index 97% rename from crates/idfoto-wasm/src/lib.rs rename to crates/relicario-wasm/src/lib.rs index 0f82f8a..2f791a3 100644 --- a/crates/idfoto-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -1,6 +1,6 @@ //! WASM bindings for the idfoto password manager. //! -//! This crate wraps [`idfoto_core`] for use in a Chrome MV3 browser extension via +//! This crate wraps [`relicario_core`] for use in a Chrome MV3 browser extension via //! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from //! JavaScript after loading the compiled `.wasm` module. //! @@ -21,10 +21,10 @@ use wasm_bindgen::prelude::*; -use idfoto_core::crypto::{self, KdfParams}; -use idfoto_core::entry::Entry; -use idfoto_core::vault; -use idfoto_core::imgsecret; +use relicario_core::crypto::{self, KdfParams}; +use relicario_core::entry::Entry; +use relicario_core::vault; +use relicario_core::imgsecret; use hmac::{Hmac, Mac}; use sha1::Sha1; @@ -103,7 +103,7 @@ pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result, let secret: [u8; 32] = secret .try_into() .map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?; - idfoto_core::imgsecret::embed(carrier_jpeg, &secret) + relicario_core::imgsecret::embed(carrier_jpeg, &secret) .map_err(|e| JsValue::from_str(&e.to_string())) } @@ -142,7 +142,7 @@ pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result, JsVa let key: &[u8; 32] = key .try_into() .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let manifest: idfoto_core::entry::Manifest = + let manifest: relicario_core::entry::Manifest = serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?; vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string())) } From 3e0cafb2698607b1e99cc53e2884add9332b6904 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 20:58:50 -0400 Subject: [PATCH 40/69] chore: update Cargo.lock after typed-item dependency additions Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 607 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 607 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f6581bc..68b9fa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -106,6 +124,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-trait" version = "0.1.89" @@ -129,6 +153,41 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -217,6 +276,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -289,6 +362,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -367,6 +446,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -409,6 +497,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -434,6 +533,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "errno" version = "0.3.14" @@ -450,6 +555,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fax" version = "0.2.6" @@ -501,6 +617,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -581,6 +706,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hmac" version = "0.12.1" @@ -590,6 +724,133 @@ dependencies = [ "digest", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -621,6 +882,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -639,6 +909,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.184" @@ -666,6 +942,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -726,6 +1008,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -919,6 +1207,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1008,6 +1311,35 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "relicario-cli" version = "0.1.0" @@ -1023,6 +1355,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "zeroize", ] [[package]] @@ -1030,14 +1363,22 @@ name = "relicario-core" version = "0.1.0" dependencies = [ "argon2", + "bip39", "chacha20poly1305", + "chrono", "ed25519-dalek", + "getrandom", + "hex", "image", "rand", "serde", "serde_json", "sha2", "thiserror 2.0.18", + "unicode-normalization", + "url", + "zeroize", + "zxcvbn", ] [[package]] @@ -1223,6 +1564,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1246,6 +1593,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1300,6 +1658,50 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.19.0" @@ -1312,6 +1714,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -1322,6 +1733,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1444,6 +1874,16 @@ version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472" +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "weezl" version = "0.1.12" @@ -1481,12 +1921,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -1636,6 +2129,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x11rb" version = "0.13.2" @@ -1653,6 +2152,29 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -1673,11 +2195,80 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zmij" @@ -1699,3 +2290,19 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zxcvbn" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" +dependencies = [ + "chrono", + "fancy-regex", + "itertools", + "lazy_static", + "regex", + "time", + "wasm-bindgen", + "web-sys", +] From 7853db061e33c398795086d22e4533c384d7ce15 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 21:27:17 -0400 Subject: [PATCH 41/69] fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3) --- crates/relicario-core/src/imgsecret.rs | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/relicario-core/src/imgsecret.rs b/crates/relicario-core/src/imgsecret.rs index d512036..e495529 100644 --- a/crates/relicario-core/src/imgsecret.rs +++ b/crates/relicario-core/src/imgsecret.rs @@ -65,6 +65,11 @@ const QUANT_STEP: f64 = 50.0; /// this cannot hold enough 8x8 blocks for reliable embedding. const MIN_DIMENSION: u32 = 100; +/// Maximum image dimension (width or height) in pixels. Images larger than +/// this are rejected before full decode to prevent DoS via attacker-supplied +/// oversized JPEGs (audit M3). +pub const MAX_DIMENSION: u32 = 10_000; + /// Number of secret bits to embed: 256 bits = 32 bytes. const SECRET_BITS: usize = 256; @@ -112,6 +117,64 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [ (2, 3), // zig-zag 14-17 ]; +// ─── Dimension guard ───────────────────────────────────────────────────────── + +/// Walk JPEG markers until we hit an SOF (start-of-frame) marker, which +/// carries the image dimensions in bytes 5..=8 of its segment. +/// +/// This peek does NOT decode any pixel data, so an oversized JPEG header is +/// rejected in O(marker-count) time without allocating a frame buffer. +fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> { + let mut i = 0; + while i + 1 < jpeg.len() { + if jpeg[i] != 0xFF { + i += 1; + continue; + } + let marker = jpeg[i + 1]; + match marker { + 0xD8 | 0xD9 => { + i += 2; + continue; + } // SOI / EOI + 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => { + // SOFn — height in [i+5..i+7], width in [i+7..i+9] + if i + 9 >= jpeg.len() { + return Err(RelicarioError::ImgSecret("truncated SOF marker".into())); + } + let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32; + let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32; + return Ok((width, height)); + } + _ => { + if i + 3 >= jpeg.len() { + return Err(RelicarioError::ImgSecret("truncated marker segment".into())); + } + let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize; + i += 2 + seg_len; + } + } + } + Err(RelicarioError::ImgSecret( + "no SOF marker found in JPEG".into(), + )) +} + +/// Reject JPEGs that claim dimensions exceeding [`MAX_DIMENSION`]. +/// +/// Called at the entry point of both `embed` and `extract` to prevent +/// attacker-supplied 32000×32000 images from wedging the WASM service worker +/// during the expensive DCT extraction pass (audit M3). +fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> { + let (w, h) = peek_jpeg_dimensions(jpeg)?; + if w > MAX_DIMENSION || h > MAX_DIMENSION { + return Err(RelicarioError::ImgSecret(format!( + "image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap" + ))); + } + Ok(()) +} + // ─── YChannel ──────────────────────────────────────────────────────────────── /// The luminance (Y) channel of an image, stored as a flat array of f64 values. @@ -601,6 +664,7 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result Result> { + enforce_dimension_cap(carrier_jpeg)?; let mut y = extract_y_channel(carrier_jpeg)?; if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION { @@ -672,6 +736,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result> { /// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered /// (image was never watermarked, or was too heavily recompressed/cropped). pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> { + enforce_dimension_cap(jpeg_bytes)?; extract_with_crop_recovery(jpeg_bytes) } @@ -1015,6 +1080,30 @@ mod tests { assert_eq!(extracted, secret); } + #[test] + fn rejects_oversized_image_without_full_decode() { + // Synthesize a JPEG header claiming 20000x20000 dimensions. + // The actual pixel data is irrelevant — the dimension peek should bail out + // before decoding any pixels. + let jpeg = build_oversized_jpeg_header(20_000, 20_000); + let result = extract(&jpeg); + assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension"))); + } + + fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec { + // SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI + let mut v = vec![0xFF, 0xD8]; // SOI + v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0 + v.extend_from_slice(b"JFIF\0"); + v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]); + v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0 + v.extend_from_slice(&height.to_be_bytes()); + v.extend_from_slice(&width.to_be_bytes()); + v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]); + v.extend_from_slice(&[0xFF, 0xD9]); // EOI + v + } + #[test] fn embed_extract_survives_10pct_crop() { let jpeg = make_test_jpeg(400, 300); From c8535e11f5dbf35db7b60cc92ed479d9220315e4 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 21:34:53 -0400 Subject: [PATCH 42/69] fix(core): correct off-by-one in imgsecret SOF bounds guard peek_jpeg_dimensions reads jpeg[i+8] as the last byte, so the guard should be \`i + 8 >= jpeg.len()\`, not \`i + 9 >= jpeg.len()\`. The old guard would reject a valid SOF marker ending exactly at len()-1. Caught in Task 2 code-quality review. --- crates/relicario-core/src/imgsecret.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/relicario-core/src/imgsecret.rs b/crates/relicario-core/src/imgsecret.rs index e495529..ab30a5a 100644 --- a/crates/relicario-core/src/imgsecret.rs +++ b/crates/relicario-core/src/imgsecret.rs @@ -139,7 +139,7 @@ fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> { } // SOI / EOI 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => { // SOFn — height in [i+5..i+7], width in [i+7..i+9] - if i + 9 >= jpeg.len() { + if i + 8 >= jpeg.len() { return Err(RelicarioError::ImgSecret("truncated SOF marker".into())); } let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32; From 6890926e310c65ee2f585c844d35010fe94063cf Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 21:37:28 -0400 Subject: [PATCH 43/69] feat(cli): add helpers module (vault_dir/L8, git_command/H4, iso8601/M11) Bumps rpassword to 7.x (H7) and adds zeroize/chrono/assert_cmd dev-deps. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/Cargo.toml | 8 ++- crates/relicario-cli/src/helpers.rs | 101 ++++++++++++++++++++++++++++ crates/relicario-cli/src/main.rs | 2 + 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 crates/relicario-cli/src/helpers.rs diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 7dca4e1..bd87584 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -12,8 +12,9 @@ path = "src/main.rs" relicario-core = { path = "../relicario-core" } clap = { version = "4", features = ["derive"] } anyhow = "1" -rpassword = "5" +rpassword = "7" arboard = "3" +chrono = { version = "0.4", default-features = false, features = ["clock"] } dirs = "5" hex = "0.4" ed25519-dalek = { version = "2", features = ["rand_core"] } @@ -21,3 +22,8 @@ rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" zeroize = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs new file mode 100644 index 0000000..28d89f5 --- /dev/null +++ b/crates/relicario-cli/src/helpers.rs @@ -0,0 +1,101 @@ +//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601 +//! timestamp formatting. Kept in their own module so every command handler +//! stays terse. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use chrono::DateTime; + +/// Walk up from `start` looking for a directory containing `.relicario/`. +/// Returns the vault root (the directory that contains `.relicario/`). +/// Audit L8: refuses to operate outside an initialized vault. +pub fn find_vault_dir_from(start: &Path) -> Result { + let mut cur = start.to_path_buf(); + loop { + if cur.join(".relicario").is_dir() { + return Ok(cur); + } + if !cur.pop() { + bail!( + "no .relicario/ directory found in {} or any parent — \ + run `relicario init` first", + start.display() + ); + } + } +} + +/// Convenience wrapper that starts the search from `std::env::current_dir()`. +pub fn vault_dir() -> Result { + let cwd = std::env::current_dir().context("failed to get current directory")?; + find_vault_dir_from(&cwd) +} + +/// Path to the `.relicario/` configuration directory within the vault. +pub fn relicario_dir() -> Result { + Ok(vault_dir()?.join(".relicario")) +} + +/// Build a hardened `git` command — no hooks, no GPG signing, no editor. +/// Audit H4: prevents vault mutations from running hostile hooks, blocking on +/// GPG passphrase prompts (which would hold the master key alive), or entering +/// $EDITOR during rebase conflict markers. +pub fn git_command(repo: &Path, args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + cmd.current_dir(repo); + cmd.args([ + "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false", + "-c", "core.editor=true", + ]); + cmd.args(args); + cmd +} + +/// Format a Unix-seconds timestamp as an ISO-8601 UTC string. +/// Audit M11: replaces the old `now_iso8601` helper that actually returned +/// a numeric string. +pub fn iso8601(unix_seconds: i64) -> String { + DateTime::from_timestamp(unix_seconds, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn vault_dir_finds_marker_in_cwd() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join(".relicario")).unwrap(); + let found = find_vault_dir_from(tmp.path()).unwrap(); + assert_eq!(found, tmp.path()); + } + + #[test] + fn vault_dir_finds_marker_in_parent() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join(".relicario")).unwrap(); + let subdir = tmp.path().join("sub/nested"); + std::fs::create_dir_all(&subdir).unwrap(); + let found = find_vault_dir_from(&subdir).unwrap(); + assert_eq!(found, tmp.path()); + } + + #[test] + fn vault_dir_errors_when_missing() { + let tmp = TempDir::new().unwrap(); + let err = find_vault_dir_from(tmp.path()).unwrap_err(); + assert!(err.to_string().contains(".relicario")); + } + + #[test] + fn iso8601_formats_fixed_timestamp() { + // 2026-04-19T00:00:00Z = 1776556800 + assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z"); + } +} diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index c41ec63..a613c66 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -37,6 +37,8 @@ //! 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. +mod helpers; + use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use relicario_core::{ From 06d21bf7c9b6371c1be53db109ed4bbf948fd253 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 21:44:44 -0400 Subject: [PATCH 44/69] feat(cli): add UnlockedVault session wrapping master_key in Zeroizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provides load/save helpers for Manifest/Settings/Item; atomic_write keeps vault files consistent across crashes. main.rs is transiently broken against the old Entry API — Task 5+ rewrites the command handlers. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/relicario-cli/src/main.rs | 1 + crates/relicario-cli/src/session.rs | 139 ++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 crates/relicario-cli/src/session.rs diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index a613c66..a1d056b 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -38,6 +38,7 @@ //! creates a git commit, preserving an audit log of all vault changes. mod helpers; +mod session; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs new file mode 100644 index 0000000..672b81f --- /dev/null +++ b/crates/relicario-cli/src/session.rs @@ -0,0 +1,139 @@ +//! Unlocked-vault session: the shape every vault-mutating command works with. +//! +//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a +//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use zeroize::Zeroizing; + +use relicario_core::{ + decrypt_item, decrypt_manifest, decrypt_settings, + derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings, + imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings, +}; + +use crate::helpers::vault_dir; + +/// A vault whose master key has been derived and is held in memory. +/// The key is wiped via `Zeroize` when this struct drops. +pub struct UnlockedVault { + root: PathBuf, + master_key: Zeroizing<[u8; 32]>, +} + +impl UnlockedVault { + pub fn root(&self) -> &Path { &self.root } + pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key } + + /// Full interactive unlock flow: locate vault, prompt passphrase, locate + /// reference image, derive master key. + pub fn unlock_interactive() -> Result { + let root = vault_dir()?; + let salt = read_salt(&root)?; + let params = read_params(&root)?; + let image_path = get_image_path()?; + let image_bytes = fs::read(&image_path) + .with_context(|| format!("failed to read reference image {}", image_path.display()))?; + let image_secret = imgsecret::extract(&image_bytes)?; + + let passphrase = Zeroizing::new( + rpassword::prompt_password("Passphrase: ") + .context("failed to read passphrase")? + ); + + let master_key = derive_master_key( + passphrase.as_bytes(), + &image_secret, + &salt, + ¶ms, + )?; + + Ok(Self { root, master_key }) + } + + pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") } + pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") } + pub fn item_path(&self, id: &ItemId) -> PathBuf { + self.root.join("items").join(format!("{}.enc", id.as_str())) + } + + pub fn load_manifest(&self) -> Result { + let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?; + Ok(decrypt_manifest(&bytes, &self.master_key)?) + } + + pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> { + let bytes = encrypt_manifest(manifest, &self.master_key)?; + atomic_write(&self.manifest_path(), &bytes) + } + + pub fn load_settings(&self) -> Result { + let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?; + Ok(decrypt_settings(&bytes, &self.master_key)?) + } + + pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> { + let bytes = encrypt_settings(settings, &self.master_key)?; + atomic_write(&self.settings_path(), &bytes) + } + + pub fn load_item(&self, id: &ItemId) -> Result { + let bytes = fs::read(self.item_path(id)) + .with_context(|| format!("failed to read item {}", id.as_str()))?; + Ok(decrypt_item(&bytes, &self.master_key)?) + } + + pub fn save_item(&self, item: &Item) -> Result<()> { + let path = self.item_path(&item.id); + if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } + let bytes = encrypt_item(item, &self.master_key)?; + atomic_write(&path, &bytes) + } +} + +fn read_salt(root: &Path) -> Result<[u8; 32]> { + let data = fs::read(root.join(".relicario").join("salt")) + .context("failed to read .relicario/salt")?; + if data.len() != 32 { bail!("invalid salt length: {}", data.len()); } + let mut salt = [0u8; 32]; + salt.copy_from_slice(&data); + Ok(salt) +} + +fn read_params(root: &Path) -> Result { + let s = fs::read_to_string(root.join(".relicario").join("params.json")) + .context("failed to read .relicario/params.json")?; + let params: KdfParams = serde_json::from_str(&s).context("failed to parse params.json")?; + Ok(params) +} + +/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt. +pub fn get_image_path() -> Result { + if let Ok(path) = std::env::var("RELICARIO_IMAGE") { + return Ok(PathBuf::from(path)); + } + // Also accept /reference.jpg as a convention. + if let Ok(root) = vault_dir() { + let default = root.join("reference.jpg"); + if default.exists() { return Ok(default); } + } + eprint!("Reference image path: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + let trimmed = line.trim(); + if trimmed.is_empty() { bail!("no reference image path provided"); } + Ok(PathBuf::from(trimmed)) +} + +/// Atomic write: write to .tmp, then rename over . Keeps the +/// vault file consistent if we crash mid-write. +fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { + let tmp = path.with_extension("tmp"); + fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?; + Ok(()) +} From 589d7b90b43b451931352c184c15f6328c427a84 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 21:57:42 -0400 Subject: [PATCH 45/69] fix(cli): zeroize image_secret + correct atomic_write temp path atomic_write now appends .tmp instead of replacing the extension (manifest.enc.tmp, not manifest.tmp). image_secret is wrapped in Zeroizing so both KDF inputs wipe on drop. Caught in Task 4 review. --- crates/relicario-cli/src/session.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 672b81f..e4bb823 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -37,7 +37,7 @@ impl UnlockedVault { let image_path = get_image_path()?; let image_bytes = fs::read(&image_path) .with_context(|| format!("failed to read reference image {}", image_path.display()))?; - let image_secret = imgsecret::extract(&image_bytes)?; + let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?); let passphrase = Zeroizing::new( rpassword::prompt_password("Passphrase: ") @@ -46,7 +46,7 @@ impl UnlockedVault { let master_key = derive_master_key( passphrase.as_bytes(), - &image_secret, + &*image_secret, &salt, ¶ms, )?; @@ -132,7 +132,9 @@ pub fn get_image_path() -> Result { /// Atomic write: write to .tmp, then rename over . Keeps the /// vault file consistent if we crash mid-write. fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { - let tmp = path.with_extension("tmp"); + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?; fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?; Ok(()) From 15e6ed9c75c80ae1dca5578a09052673ee39a6e6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:00:14 -0400 Subject: [PATCH 46/69] feat(cli): scaffold clap surface for all typed-item commands Every subcommand from the Plan 1B CLI spec present; bodies return 'not yet implemented' so subsequent tasks land one command at a time. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/src/main.rs | 1081 +++++++----------------------- 1 file changed, 230 insertions(+), 851 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index a1d056b..33d20a6 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1,895 +1,274 @@ -//! relicario CLI -- the platform layer for the relicario password manager. +//! relicario CLI — the platform layer for the relicario password manager. //! -//! This binary provides the filesystem, git, and terminal I/O that -//! [`relicario_core`] intentionally excludes. It is the "glue" between the -//! platform-agnostic core library and the user's local environment. -//! -//! ## Vault layout on disk -//! -//! ```text -//! / -//! .relicario/ -//! 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 `RELICARIO_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 `.relicario/`. -//! 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. +//! See module docs for the unlock flow and vault layout. mod helpers; mod session; -use anyhow::{bail, Context, Result}; -use clap::{Parser, Subcommand}; -use relicario_core::{ - decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id, - Entry, KdfParams, Manifest, ManifestEntry, -}; -use zeroize::Zeroizing; -use rand::rngs::OsRng; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::io::{self, BufRead, Write}; use std::path::PathBuf; -use std::process::Command; -// ─── CLI structure ────────────────────────────────────────────────────────── +use anyhow::{bail, Result}; +use clap::{Parser, Subcommand}; -/// Top-level CLI argument parser. #[derive(Parser)] #[command( name = "relicario", version, - about = "Git-backed password manager with reference image authentication" + about = "Git-backed password manager with reference-image two-factor unlock" )] struct Cli { #[command(subcommand)] command: Commands, } -/// All available CLI subcommands. #[derive(Subcommand)] enum Commands { - /// Initialize a new relicario vault in the current directory. - /// Creates the directory structure, generates a random image secret, - /// embeds it in the carrier image, and sets up git. + /// Initialize a new vault in the current directory. Init { - /// Path to the carrier JPEG image to embed the secret into. + /// Carrier JPEG to embed the secret into. #[arg(long)] image: PathBuf, - /// Output path for the reference image (with embedded secret). + /// Output path for the reference image (gitignored). #[arg(long, default_value = "reference.jpg")] output: PathBuf, }, - /// 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 (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 in the vault (names, URLs, usernames only -- no passwords). - List, - /// Edit an existing entry by name (fuzzy search). - /// Shows current values and lets you selectively update fields. - Edit { name: String }, - /// Remove an entry from the vault by name (fuzzy search). - /// Prompts for confirmation before deleting. - Rm { name: String }, - /// Sync the vault with the git remote (pull --rebase, then push). - Sync, - /// 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, + + /// Add a new item. Type-specific flags populate the core; missing fields + /// are prompted for interactively. + Add { + #[command(subcommand)] + kind: AddKind, }, - /// 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. + + /// Print an item. Secrets are masked by default; pass --show to reveal. + Get { + /// Item id or case-insensitive title substring. + query: String, + /// Print secret field values in plaintext. + #[arg(long)] + show: bool, + /// Copy the primary secret (Login.password, Card.number, etc.) to clipboard. + #[arg(long)] + copy: bool, + }, + + /// List items. + List { + #[arg(long)] + r#type: Option, + #[arg(long)] + group: Option, + #[arg(long)] + tag: Option, + #[arg(long)] + trashed: bool, + }, + + /// Edit an item interactively. + Edit { query: String }, + + /// Soft-delete an item (moves to trash; reversible via `restore`). + Rm { query: String }, + + /// Restore a soft-deleted item. + Restore { query: String }, + + /// Permanently purge an item (and its attachments). + Purge { query: String }, + + /// Trash operations. + Trash { + #[command(subcommand)] + action: TrashAction, + }, + + /// Attach a file to an item. + Attach { query: String, file: PathBuf }, + + /// List attachments on an item. + Attachments { query: String }, + + /// Extract an attachment to disk. + Extract { + query: String, + aid: String, + #[arg(long)] + out: Option, + }, + + /// Generate a password or passphrase. + Generate { + #[arg(long, default_value_t = 20)] + length: u32, + #[arg(long)] + bip39: bool, + #[arg(long, default_value_t = 5)] + words: u32, + #[arg(long, default_value = "safe")] + symbols: String, + /// Separator for BIP39 words. + #[arg(long, default_value = " ")] + separator: String, + }, + + /// View or change vault settings. + Settings { + #[command(subcommand)] + action: SettingsAction, + }, + + /// Sync with the git remote (pull --rebase + push). + Sync, + + /// Device management. Device { #[command(subcommand)] - action: DeviceCommands, + action: DeviceAction, + }, + + /// Lock the vault (no-op in CLI; present for UX parity with the extension). + Lock, +} + +#[derive(Subcommand)] +enum AddKind { + Login { + #[arg(long)] title: Option, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + /// Prompt for password (vs reading from stdin or --password). + #[arg(long)] password_prompt: bool, + #[arg(long)] password: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] favorite: bool, + }, + SecureNote { + #[arg(long)] title: Option, + #[arg(long)] body_prompt: bool, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Identity { + #[arg(long)] title: Option, + #[arg(long)] full_name: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long)] date_of_birth: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Card { + #[arg(long)] title: Option, + #[arg(long)] holder: Option, + #[arg(long)] expiry: Option, // MM/YYYY + #[arg(long, default_value = "credit")] kind: String, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Key { + #[arg(long)] title: Option, + #[arg(long)] label: Option, + #[arg(long)] algorithm: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Document { + #[arg(long)] title: Option, + #[arg(long)] file: PathBuf, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Totp { + #[arg(long)] title: Option, + #[arg(long)] issuer: Option, + #[arg(long)] label: Option, + #[arg(long)] secret: Option, // base32 + #[arg(long, default_value = "30")] period: u32, + #[arg(long, default_value = "6")] digits: u8, + #[arg(long, default_value = "sha1")] algorithm: String, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, }, } -/// Subcommands for device key management. #[derive(Subcommand)] -enum DeviceCommands { - /// 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 all registered devices and their public keys. +enum TrashAction { + /// List trashed items. + List, + /// Purge every trashed item past its retention window. + Empty, +} + +#[derive(Subcommand)] +enum SettingsAction { + /// Show current settings as JSON. + Show, + /// Set trash retention (e.g., --days 30 or --forever). + TrashRetention { + #[arg(long)] days: Option, + #[arg(long)] forever: bool, + }, + /// Set field history retention. + HistoryRetention { + #[arg(long)] last_n: Option, + #[arg(long)] days: Option, + #[arg(long)] forever: bool, + }, + /// Set per-attachment max size in bytes. + AttachmentCap { + #[arg(long)] per_attachment_max_bytes: Option, + #[arg(long)] per_item_max_count: Option, + #[arg(long)] per_vault_soft_cap_bytes: Option, + #[arg(long)] per_vault_hard_cap_bytes: Option, + }, +} + +#[derive(Subcommand)] +enum DeviceAction { + Add { #[arg(long)] name: String }, List, - /// 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 `.relicario/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 `relicario` is invoked. -fn vault_dir() -> PathBuf { - std::env::current_dir().expect("failed to get current directory") -} - -/// Returns the path to the `.relicario/` configuration directory within the vault. -fn relicario_dir() -> PathBuf { - vault_dir().join(".relicario") -} - -/// Read the 32-byte vault salt from `.relicario/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(relicario_dir().join("salt")).context("failed to read salt")?; - let mut salt = [0u8; 32]; - if data.len() != 32 { - bail!("invalid salt file: expected 32 bytes, got {}", data.len()); - } - salt.copy_from_slice(&data); - Ok(salt) -} - -/// Read the KDF parameters from `.relicario/params.json`. -fn read_params() -> Result { - let data = fs::read_to_string(relicario_dir().join("params.json")) - .context("failed to read params.json")?; - let params: KdfParams = serde_json::from_str(&data).context("failed to parse params.json")?; - Ok(params) -} - -/// Locate the reference image path. -/// -/// First checks the `RELICARIO_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("RELICARIO_IMAGE") { - return Ok(PathBuf::from(path)); - } - let path = prompt("Reference image path")?; - 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> { - let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?; - - let jpeg_data = fs::read(image_path).context("failed to read reference image")?; - let image_secret = - relicario_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?; - - let salt = read_salt()?; - let params = read_params()?; - - let master_key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) - .context("failed to derive master key")?; - - 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"]) - .status() - .context("failed to run git add")?; - if !status.success() { - bail!("git add failed"); - } - - let status = Command::new("git") - .args(["commit", "-m", message]) - .status() - .context("failed to run git commit")?; - if !status.success() { - bail!("git commit failed"); - } - - 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) - .unwrap_or_default(); - 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()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - 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() { - Ok(None) - } else { - Ok(Some(value)) - } -} - -/// 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()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - let trimmed = line.trim(); - if trimmed.is_empty() { - Ok(current.to_string()) - } else { - Ok(trimmed.to_string()) - } -} - -/// 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; - (0..length) - .map(|_| { - let idx = (rng.next_u32() as usize) % CHARSET.len(); - CHARSET[idx] as char - }) - .collect() -} - -// ─── Command implementations ──────────────────────────────────────────────── - -/// Initialize a new relicario 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 (.relicario/, 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")?; - - // 2. Generate random image_secret - let mut image_secret = [0u8; 32]; - OsRng.fill_bytes(&mut image_secret); - - // 3. Embed secret into carrier - let reference_jpeg = - relicario_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?; - - // 4. Save reference JPEG - fs::write(&output, &reference_jpeg).context("failed to write reference image")?; - eprintln!("Reference image saved to {}", output.display()); - - // 5. Prompt for passphrase - let passphrase = loop { - let p1 = rpassword::prompt_password_stderr("Passphrase (min 8 chars): ") - .context("failed to read passphrase")?; - if p1.len() < 8 { - eprintln!("Passphrase must be at least 8 characters."); - continue; - } - let p2 = rpassword::prompt_password_stderr("Confirm passphrase: ") - .context("failed to read passphrase confirmation")?; - if p1 != p2 { - eprintln!("Passphrases do not match."); - continue; - } - break p1; - }; - - // 6. Generate random salt - let mut salt = [0u8; 32]; - OsRng.fill_bytes(&mut salt); - - // 7. Derive master key - let params = KdfParams::default(); - let master_key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) - .context("failed to derive master key")?; - - // 8. Create directory structure - let relicario = relicario_dir(); - fs::create_dir_all(&relicario).context("failed to create .relicario directory")?; - fs::create_dir_all(vault_dir().join("entries")).context("failed to create entries directory")?; - - // 9. Write config files - fs::write(relicario.join("salt"), &salt).context("failed to write salt")?; - fs::write( - relicario.join("params.json"), - serde_json::to_string_pretty(¶ms)?, - ) - .context("failed to write params.json")?; - fs::write(relicario.join("devices.json"), "[]").context("failed to write devices.json")?; - - // 10. Encrypt empty manifest - let manifest = Manifest::new(); - let manifest_enc = encrypt_manifest(&*master_key, &manifest).context("failed to encrypt manifest")?; - fs::write(vault_dir().join("manifest.enc"), manifest_enc) - .context("failed to write manifest.enc")?; - - // 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")?; - - // 12. Git init and commit - let status = Command::new("git").arg("init").status()?; - if !status.success() { - bail!("git init failed"); - } - git_commit("feat: initialize relicario vault")?; - - // 13. Success - eprintln!("Vault initialized successfully."); - eprintln!("IMPORTANT: Keep your reference image safe — you need it to unlock the vault."); - - 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)?; - - let name = prompt("Name")?; - if name.is_empty() { - bail!("Name cannot be empty"); - } - - let url = prompt_optional("URL (optional)")?; - let username = prompt_optional("Username (optional)")?; - - let password = { - let p = prompt_optional("Password (Enter to auto-generate)")?; - match p { - Some(pw) if !pw.is_empty() => pw, - _ => { - let gen = generate_password(20); - eprintln!("Generated password: {}", gen); - gen - } - } - }; - - let notes = prompt_optional("Notes (optional)")?; - let totp_secret = prompt_optional("TOTP secret (optional)")?; - - let now = now_iso8601(); - let entry = Entry { - name: name.clone(), - url: url.clone(), - username: username.clone(), - password, - notes, - totp_secret, - group: None, - created_at: now.clone(), - updated_at: now.clone(), - }; - - let entry_id = generate_entry_id(); - let encrypted = encrypt_entry(&*master_key, &entry).context("failed to encrypt entry")?; - fs::write( - vault_dir().join("entries").join(format!("{}.enc", entry_id)), - encrypted, - ) - .context("failed to write entry file")?; - - let mut manifest = read_manifest(&*master_key)?; - manifest.add_entry( - entry_id.clone(), - ManifestEntry { - name: name.clone(), - url, - username, - group: None, - updated_at: now, - }, - ); - write_manifest(&*master_key, &manifest)?; - - git_commit(&format!("feat: add entry '{}'", name))?; - eprintln!("Entry '{}' added (id: {})", name, entry_id); - - 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() { - bail!("no entries matching '{}'", query); - } - - if results.len() == 1 { - let (id, entry) = results[0]; - return Ok((id.clone(), entry.clone())); - } - - eprintln!("Multiple matches:"); - for (i, (id, entry)) in results.iter().enumerate() { - eprintln!( - " {}) {} (id: {}, url: {})", - i + 1, - entry.name, - id, - entry.url.as_deref().unwrap_or("-") - ); - } - - let choice = prompt("Choose entry number")?; - let idx: usize = choice.parse::().context("invalid number")? - 1; - if idx >= results.len() { - bail!("invalid selection"); - } - - let (id, entry) = results[idx]; - 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)?; - - let manifest = read_manifest(&*master_key)?; - let (entry_id, _) = search_and_select(&manifest, &query)?; - - let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id))) - .context("failed to read entry file")?; - let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?; - - println!("Name: {}", entry.name); - println!( - "URL: {}", - entry.url.as_deref().unwrap_or("-") - ); - println!( - "Username: {}", - entry.username.as_deref().unwrap_or("-") - ); - println!("Password: {}", entry.password); - if let Some(notes) = &entry.notes { - println!("Notes: {}", notes); - } - if let Some(totp) = &entry.totp_secret { - println!("TOTP: {}", totp); - } - - // 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() { - eprintln!("Password copied to clipboard (clearing in 30s)"); - let pw = entry.password.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(30)); - if let Ok(mut cb) = arboard::Clipboard::new() { - if let Ok(current) = cb.get_text() { - if current == pw { - let _ = cb.set_text(""); - } - } - } - }); - } - } - Err(_) => { - eprintln!("(clipboard unavailable)"); - } - } - - 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)?; - - let manifest = read_manifest(&*master_key)?; - - let mut entries: Vec<_> = manifest.entries.iter().collect(); - entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase())); - - if entries.is_empty() { - eprintln!("No entries in vault."); - return Ok(()); - } - - println!("{:<10} {:<30} {:<30} {}", "ID", "Name", "URL", "Username"); - println!("{}", "-".repeat(80)); - for (id, entry) in entries { - println!( - "{:<10} {:<30} {:<30} {}", - id, - entry.name, - entry.url.as_deref().unwrap_or("-"), - entry.username.as_deref().unwrap_or("-") - ); - } - - 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)?; - - let manifest = read_manifest(&*master_key)?; - let (entry_id, _) = search_and_select(&manifest, &query)?; - - let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id))) - .context("failed to read entry file")?; - let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?; - - eprintln!("Editing '{}' (Enter to keep current value)", entry.name); - - let name = prompt_with_default("Name", &entry.name)?; - let url = prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?; - let url = if url.is_empty() { None } else { Some(url) }; - let username = prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?; - let username = if username.is_empty() { - None - } else { - Some(username) - }; - let password = prompt_with_default("Password", &entry.password)?; - let notes = prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?; - let notes = if notes.is_empty() { None } else { Some(notes) }; - let totp_secret = prompt_with_default("TOTP secret", entry.totp_secret.as_deref().unwrap_or(""))?; - let totp_secret = if totp_secret.is_empty() { - None - } else { - Some(totp_secret) - }; - - let now = now_iso8601(); - let updated_entry = Entry { - name: name.clone(), - url: url.clone(), - username: username.clone(), - password, - notes, - totp_secret, - group: entry.group, - created_at: entry.created_at, - updated_at: now.clone(), - }; - - let encrypted = encrypt_entry(&*master_key, &updated_entry).context("failed to encrypt entry")?; - fs::write( - vault_dir().join("entries").join(format!("{}.enc", entry_id)), - encrypted, - ) - .context("failed to write entry file")?; - - let mut manifest = read_manifest(&*master_key)?; - manifest.add_entry( - entry_id, - ManifestEntry { - name: name.clone(), - url, - username, - group: updated_entry.group, - updated_at: now, - }, - ); - write_manifest(&*master_key, &manifest)?; - - git_commit(&format!("feat: edit entry '{}'", name))?; - eprintln!("Entry '{}' updated.", name); - - 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)?; - - let manifest = read_manifest(&*master_key)?; - let (entry_id, entry) = search_and_select(&manifest, &query)?; - - let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?; - if confirm.to_lowercase() != "y" { - eprintln!("Cancelled."); - return Ok(()); - } - - let entry_path = vault_dir() - .join("entries") - .join(format!("{}.enc", entry_id)); - if entry_path.exists() { - fs::remove_file(&entry_path).context("failed to remove entry file")?; - } - - let mut manifest = read_manifest(&*master_key)?; - manifest.remove_entry(&entry_id); - write_manifest(&*master_key, &manifest)?; - - git_commit(&format!("feat: remove entry '{}'", entry.name))?; - eprintln!("Entry '{}' removed.", entry.name); - - 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") - .args(["pull", "--rebase"]) - .status() - .context("failed to run git pull")?; - if !status.success() { - bail!("git pull --rebase failed"); - } - - eprintln!("Pushing..."); - let status = Command::new("git") - .arg("push") - .status() - .context("failed to run git push")?; - if !status.success() { - bail!("git push failed"); - } - - eprintln!("Sync complete."); - Ok(()) -} - -// ─── Device management ────────────────────────────────────────────────────── - -/// Read the device registry from `.relicario/devices.json`. -fn read_devices() -> Result> { - let path = relicario_dir().join("devices.json"); - let data = fs::read_to_string(&path).context("failed to read devices.json")?; - let devices: Vec = serde_json::from_str(&data).context("failed to parse devices.json")?; - Ok(devices) -} - -/// Write the device registry to `.relicario/devices.json`. -fn write_devices(devices: &[DeviceEntry]) -> Result<()> { - let data = serde_json::to_string_pretty(devices)?; - fs::write(relicario_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/relicario/.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 device names - if devices.iter().any(|d| d.name == name) { - bail!("device '{}' already exists", name); - } - - // 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 to the user's config directory (NOT in the vault) - let config_dir = dirs::config_dir() - .context("failed to find config directory")? - .join("relicario"); - fs::create_dir_all(&config_dir).context("failed to create config directory")?; - let key_path = config_dir.join(format!("{}.key", name)); - fs::write(&key_path, &private_key_hex).context("failed to write private key")?; - - // Set restrictive permissions on the key file (Unix only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; - } - - // Add public key to the vault's device registry - devices.push(DeviceEntry { - name: name.clone(), - public_key: public_key_hex, - }); - write_devices(&devices)?; - - git_commit(&format!("feat: add device '{}'", name))?; - eprintln!("Device '{}' added.", name); - eprintln!("Private key saved to {}", key_path.display()); - - Ok(()) -} - -/// List all registered devices with their public keys. -fn cmd_device_list() -> Result<()> { - let devices = read_devices()?; - - if devices.is_empty() { - eprintln!("No devices registered."); - return Ok(()); - } - - println!("{:<20} {}", "Name", "Public Key"); - println!("{}", "-".repeat(60)); - for device in &devices { - println!("{:<20} {}", device.name, device.public_key); - } - - 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(); - devices.retain(|d| d.name != name); - - if devices.len() == initial_len { - bail!("device '{}' not found", name); - } - - write_devices(&devices)?; - git_commit(&format!("feat: revoke device '{}'", name))?; - eprintln!("Device '{}' revoked.", name); - - Ok(()) -} - -// ─── Main ─────────────────────────────────────────────────────────────────── - -/// Entry point: parse CLI arguments and dispatch to the appropriate command handler. 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::Add { kind } => cmd_add(kind), + Commands::Get { query, show, copy } => cmd_get(query, show, copy), + Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed), + Commands::Edit { query } => cmd_edit(query), + Commands::Rm { query } => cmd_rm(query), + Commands::Restore { query } => cmd_restore(query), + Commands::Purge { query } => cmd_purge(query), + Commands::Trash { action } => cmd_trash(action), + Commands::Attach { query, file } => cmd_attach(query, file), + Commands::Attachments { query } => cmd_attachments(query), + Commands::Extract { query, aid, out } => cmd_extract(query, aid, out), + Commands::Generate { length, bip39, words, symbols, separator } => { + cmd_generate(length, bip39, words, symbols, separator) + } + Commands::Settings { action } => cmd_settings(action), Commands::Sync => cmd_sync(), - Commands::Generate { length } => cmd_generate(length), - Commands::Device { action } => match action { - DeviceCommands::Add { name } => cmd_device_add(name), - DeviceCommands::List => cmd_device_list(), - DeviceCommands::Revoke { name } => cmd_device_revoke(name), - }, + Commands::Device { action } => cmd_device(action), + Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } } } + +fn cmd_init(_image: PathBuf, _output: PathBuf) -> Result<()> { bail!("not yet implemented"); } +fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); } +fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_list(_t: Option, _g: Option, _tag: Option, _trashed: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_trash(_action: TrashAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_attach(_q: String, _file: PathBuf) -> Result<()> { bail!("not yet implemented"); } +fn cmd_attachments(_q: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_extract(_q: String, _aid: String, _out: Option) -> Result<()> { bail!("not yet implemented"); } +fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } +fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); } From a50099a066b5c86af000e7afc743e691a921663b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:02:53 -0400 Subject: [PATCH 47/69] feat(cli): relicario init creates a format-v2 vault Prompts for a strong passphrase (zxcvbn gate via core), generates a 32-byte image secret, embeds it in the carrier JPEG, writes the standard vault skeleton, and makes an initial git commit via the hardened git_command helper. --- crates/relicario-cli/src/main.rs | 108 ++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 33d20a6..edca410 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -7,7 +7,7 @@ mod session; use std::path::PathBuf; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; #[derive(Parser)] @@ -256,7 +256,94 @@ fn main() -> Result<()> { } } -fn cmd_init(_image: PathBuf, _output: PathBuf) -> Result<()> { bail!("not yet implemented"); } +fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { + use std::fs; + use rand::{rngs::OsRng, RngCore}; + use relicario_core::{ + derive_master_key, encrypt_manifest, encrypt_settings, imgsecret, + validate_passphrase_strength, KdfParams, Manifest, VaultSettings, + }; + use zeroize::Zeroizing; + + let root = std::env::current_dir()?; + let relicario_dir = root.join(".relicario"); + if relicario_dir.exists() { + anyhow::bail!(".relicario/ already exists in {}", root.display()); + } + + // Passphrase with strength gate (audit H3). + let passphrase = Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?); + let confirm = Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?); + if passphrase.as_str() != confirm.as_str() { + anyhow::bail!("passphrases do not match"); + } + if let Err(e) = validate_passphrase_strength(&passphrase) { + anyhow::bail!("{}. Choose a longer or more entropic phrase.", e); + } + + // Image secret: 32 random bytes, embedded in the carrier. + let mut image_secret = [0u8; 32]; + OsRng.fill_bytes(&mut image_secret); + let carrier = fs::read(&image) + .with_context(|| format!("failed to read carrier image {}", image.display()))?; + let stego = imgsecret::embed(&carrier, &image_secret)?; + fs::write(&output, &stego) + .with_context(|| format!("failed to write reference image {}", output.display()))?; + + // Vault salt + KDF params. + let mut salt = [0u8; 32]; + OsRng.fill_bytes(&mut salt); + let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; + + // Derive master key, then persist an empty Manifest + default VaultSettings. + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; + + fs::create_dir_all(&relicario_dir)?; + fs::create_dir_all(root.join("items"))?; + fs::create_dir_all(root.join("attachments"))?; + fs::write(relicario_dir.join("salt"), salt)?; + fs::write( + relicario_dir.join("params.json"), + serde_json::to_string_pretty(&ParamsFile { + format_version: 2, + kdf: ParamsKdf { + algorithm: "argon2id-v0x13".into(), + argon2_m: params.argon2_m, + argon2_t: params.argon2_t, + argon2_p: params.argon2_p, + }, + aead: "xchacha20poly1305".into(), + salt_path: ".relicario/salt".into(), + })?, + )?; + fs::write(relicario_dir.join("devices.json"), b"[]")?; + + let manifest = Manifest::new(); + fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; + let settings = VaultSettings::default(); + fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?; + + // .gitignore excludes the reference image. + let gitignore = format!("{}\n", output.file_name().unwrap().to_string_lossy()); + fs::write(root.join(".gitignore"), gitignore)?; + + // git init + initial commit via hardened wrapper. + let status = crate::helpers::git_command(&root, &["init"]).status()?; + if !status.success() { anyhow::bail!("git init failed"); } + let _ = crate::helpers::git_command(&root, &[ + "add", ".gitignore", ".relicario/params.json", ".relicario/devices.json", + "manifest.enc", "settings.enc", + ]).status()?; + let status = crate::helpers::git_command(&root, &[ + "commit", "-m", "init: new relicario vault (format v2)", + ]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + + eprintln!("Vault initialized at {}", root.display()); + eprintln!("Reference image: {}", output.display()); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + Ok(()) +} fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); } fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); } fn cmd_list(_t: Option, _g: Option, _tag: Option, _trashed: bool) -> Result<()> { bail!("not yet implemented"); } @@ -272,3 +359,20 @@ fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result< fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); } fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); } + +#[derive(serde::Serialize)] +struct ParamsFile { + format_version: u32, + kdf: ParamsKdf, + aead: String, + salt_path: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case")] +struct ParamsKdf { + algorithm: String, + argon2_m: u32, + argon2_t: u32, + argon2_p: u32, +} From 5dce2c10f94b355e155b7508ee7dee195e97ec46 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:11:10 -0400 Subject: [PATCH 48/69] fix(cli): init stages salt, handles --output ..-paths, zeroizes image_secret 1. Add .relicario/salt to the initial git commit so it syncs across devices (Argon2 salt must match at unlock time). 2. Return a proper error instead of panicking when --output has no filename component (e.g., trailing ..). 3. Wrap the generated 32-byte image_secret in Zeroizing for consistency with the passphrase + master_key handling in Task 4. Caught in Task 6 review. --- crates/relicario-cli/src/main.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index edca410..ce187b9 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -282,11 +282,14 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { } // Image secret: 32 random bytes, embedded in the carrier. - let mut image_secret = [0u8; 32]; - OsRng.fill_bytes(&mut image_secret); + let image_secret = { + let mut buf = Zeroizing::new([0u8; 32]); + OsRng.fill_bytes(buf.as_mut_slice()); + buf + }; let carrier = fs::read(&image) .with_context(|| format!("failed to read carrier image {}", image.display()))?; - let stego = imgsecret::embed(&carrier, &image_secret)?; + let stego = imgsecret::embed(&carrier, &*image_secret)?; fs::write(&output, &stego) .with_context(|| format!("failed to write reference image {}", output.display()))?; @@ -296,7 +299,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; // Derive master key, then persist an empty Manifest + default VaultSettings. - let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; + let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?; fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(root.join("items"))?; @@ -324,7 +327,10 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?; // .gitignore excludes the reference image. - let gitignore = format!("{}\n", output.file_name().unwrap().to_string_lossy()); + let fname = output.file_name() + .ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))? + .to_string_lossy(); + let gitignore = format!("{fname}\n"); fs::write(root.join(".gitignore"), gitignore)?; // git init + initial commit via hardened wrapper. @@ -332,7 +338,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { if !status.success() { anyhow::bail!("git init failed"); } let _ = crate::helpers::git_command(&root, &[ "add", ".gitignore", ".relicario/params.json", ".relicario/devices.json", - "manifest.enc", "settings.enc", + ".relicario/salt", "manifest.enc", "settings.enc", ]).status()?; let status = crate::helpers::git_command(&root, &[ "commit", "-m", "init: new relicario vault (format v2)", From 89b22cb089fd7c4bbcb3371ab60a9e5105e59d01 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:13:17 -0400 Subject: [PATCH 49/69] feat(cli): relicario add login with flag + interactive prompting Unlocks vault, builds LoginCore from flags (password via rpassword if --password-prompt), saves item + manifest, commits via hardened git. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/Cargo.toml | 1 + crates/relicario-cli/src/main.rs | 82 +++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index bd87584..5f6a773 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -22,6 +22,7 @@ rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" zeroize = "1" +url = "2" [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index ce187b9..1662f02 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -350,7 +350,87 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); Ok(()) } -fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); } +fn cmd_add(kind: AddKind) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + + let item = match kind { + AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => { + use relicario_core::item_types::LoginCore; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let username = username.or_else(|| prompt_optional("Username").ok().flatten()); + let url = url.or_else(|| prompt_optional("URL").ok().flatten()); + let parsed_url = match url { + Some(s) => Some(url::Url::parse(&s) + .with_context(|| format!("invalid URL: {s}"))?), + None => None, + }; + let password = if let Some(p) = password { + Some(Zeroizing::new(p)) + } else if password_prompt { + Some(Zeroizing::new(rpassword::prompt_password("Password: ")?)) + } else { + None + }; + let core = ItemCore::Login(LoginCore { + username, + password, + url: parsed_url, + totp: None, + }); + let mut item = Item::new(title, core); + item.group = group; + item.tags = tags; + item.favorite = favorite; + item + } + // Task 8 fills in the other variants. + _ => anyhow::bail!("item kind not yet implemented"), + }; + + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + + commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + + eprintln!("Added: {} (id={})", item.title, item.id.as_str()); + Ok(()) +} + +fn prompt(label: &str) -> Result { + eprint!("{label}: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { anyhow::bail!("{label} required"); } + Ok(trimmed) +} + +fn prompt_optional(label: &str) -> Result> { + eprint!("{label} (leave blank to skip): "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> { + let mut args: Vec<&str> = vec!["add"]; + args.extend_from_slice(paths); + let status = crate::helpers::git_command(vault.root(), &args).status()?; + if !status.success() { anyhow::bail!("git add failed"); } + let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + Ok(()) +} + fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); } fn cmd_list(_t: Option, _g: Option, _tag: Option, _trashed: bool) -> Result<()> { bail!("not yet implemented"); } fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } From fe017455d3dd607bf515a7580fa5cb487720f74e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:16:51 -0400 Subject: [PATCH 50/69] =?UTF-8?q?feat(cli):=20relicario=20add=20=E2=80=94?= =?UTF-8?q?=20remaining=206=20item=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecureNote, Identity, Card, Key, Document (with inline attachment), and Totp with base32 secret decoding. Document widens the commit to include the attachment blob path. --- crates/relicario-cli/Cargo.toml | 1 + crates/relicario-cli/src/main.rs | 234 ++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 5f6a773..76574c5 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" zeroize = "1" url = "2" +data-encoding = "2" [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 1662f02..dec59b0 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -387,16 +387,202 @@ fn cmd_add(kind: AddKind) -> Result<()> { item.favorite = favorite; item } - // Task 8 fills in the other variants. - _ => anyhow::bail!("item kind not yet implemented"), + AddKind::SecureNote { title, body_prompt, group, tags } => { + use relicario_core::item_types::SecureNoteCore; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let body = if body_prompt { + eprintln!("Enter note body; end with Ctrl-D on a blank line:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + s + } else { + prompt("Body")? + }; + let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new(body), + })); + item.group = group; + item.tags = tags; + item + } + + AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => { + use relicario_core::item_types::IdentityCore; + use relicario_core::{Item, ItemCore}; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let dob = match date_of_birth { + Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") + .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?), + None => None, + }; + let mut item = Item::new(title, ItemCore::Identity(IdentityCore { + full_name, + address: None, + phone, + email, + date_of_birth: dob, + })); + item.group = group; + item.tags = tags; + item + } + + AddKind::Card { title, holder, expiry, kind, group, tags } => { + use relicario_core::item_types::{CardCore, CardKind}; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let number = Zeroizing::new(rpassword::prompt_password("Card number: ")?); + let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?); + let cvv = if cvv.is_empty() { None } else { Some(cvv) }; + let pin = Zeroizing::new(rpassword::prompt_password("PIN (blank to skip): ")?); + let pin = if pin.is_empty() { None } else { Some(pin) }; + + let parsed_expiry = match expiry { + Some(s) => Some(parse_month_year(&s)?), + None => None, + }; + let parsed_kind = match kind.as_str() { + "credit" => CardKind::Credit, + "debit" => CardKind::Debit, + "gift" => CardKind::Gift, + "loyalty" => CardKind::Loyalty, + "other" => CardKind::Other, + other => anyhow::bail!("unknown card kind: {other}"), + }; + + let mut item = Item::new(title, ItemCore::Card(CardCore { + number: Some(number), + holder, + expiry: parsed_expiry, + cvv, + pin, + kind: parsed_kind, + })); + item.group = group; + item.tags = tags; + item + } + + AddKind::Key { title, label, algorithm, group, tags } => { + use relicario_core::item_types::KeyCore; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + eprintln!("Paste key material; end with Ctrl-D on a blank line:"); + let mut key_material = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?; + if key_material.trim().is_empty() { anyhow::bail!("key material required"); } + let public_key = prompt_optional("Public key (blank to skip)")?; + + let mut item = Item::new(title, ItemCore::Key(KeyCore { + key_material: Zeroizing::new(key_material), + label, + public_key, + algorithm, + })); + item.group = group; + item.tags = tags; + item + } + + AddKind::Document { title, file, group, tags } => { + use relicario_core::item_types::DocumentCore; + use relicario_core::{ + encrypt_attachment, AttachmentRef, Item, ItemCore, + }; + use std::fs; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let bytes = fs::read(&file) + .with_context(|| format!("failed to read {}", file.display()))?; + let caps = vault.load_settings()?.attachment_caps; + let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?; + + let filename = file.file_name() + .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))? + .to_string_lossy() + .into_owned(); + let mime_type = guess_mime(&filename); + + let primary_attachment = enc.id.clone(); + let mut item = Item::new(title, ItemCore::Document(DocumentCore { + filename: filename.clone(), + mime_type: mime_type.clone(), + primary_attachment: primary_attachment.clone(), + })); + item.group = group; + item.tags = tags; + item.attachments.push(AttachmentRef { + id: primary_attachment.clone(), + filename, + mime_type, + size: bytes.len() as u64, + created: item.created, + }); + + // Persist the attachment blob before we return from the match arm. + let att_dir = vault.root().join("attachments").join(item.id.as_str()); + fs::create_dir_all(&att_dir)?; + fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?; + item + } + + AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => { + use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind}; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let secret_b32 = match secret { + Some(s) => s, + None => rpassword::prompt_password("TOTP secret (base32): ")?, + }; + let secret_bytes = base32_decode_lenient(&secret_b32)?; + let algo = match algorithm.to_ascii_lowercase().as_str() { + "sha1" => TotpAlgorithm::Sha1, + "sha256" => TotpAlgorithm::Sha256, + "sha512" => TotpAlgorithm::Sha512, + other => anyhow::bail!("unknown algorithm: {other}"), + }; + + let core = TotpCore { + config: TotpConfig { + secret: Zeroizing::new(secret_bytes), + algorithm: algo, + digits, + period_seconds: period, + kind: TotpKind::Totp, + }, + issuer, + label, + }; + let mut item = Item::new(title, ItemCore::Totp(core)); + item.group = group; + item.tags = tags; + item + } }; vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; - commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), - &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + let mut paths: Vec = vec![ + format!("items/{}.enc", item.id.as_str()), + "manifest.enc".into(), + ]; + for att in &item.attachments { + paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str())); + } + let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?; eprintln!("Added: {} (id={})", item.title, item.id.as_str()); Ok(()) @@ -421,6 +607,46 @@ fn prompt_optional(label: &str) -> Result> { Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) } +fn parse_month_year(s: &str) -> Result { + // Accepts MM/YYYY or MM-YYYY or MM/YY. + let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-') + .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; + let month: u8 = m_str.parse().context("invalid month")?; + let year: u16 = if y_str.len() == 2 { + 2000 + y_str.parse::().context("invalid 2-digit year")? + } else { + y_str.parse().context("invalid year")? + }; + Ok(relicario_core::MonthYear { month, year }) +} + +fn guess_mime(filename: &str) -> String { + let lower = filename.to_ascii_lowercase(); + match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "txt" => "text/plain", + "json" => "application/json", + _ => "application/octet-stream", + }.to_string() +} + +fn base32_decode_lenient(s: &str) -> Result> { + let cleaned: String = s.chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_uppercase() + .trim_end_matches('=') + .to_string(); + let padded = { + let rem = cleaned.len() % 8; + if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) } + }; + data_encoding::BASE32.decode(padded.as_bytes()) + .map_err(|e| anyhow::anyhow!("invalid base32: {e}")) +} + fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> { let mut args: Vec<&str> = vec!["add"]; args.extend_from_slice(paths); From ed451041b064bec5395f432bcf25c0f4c9e24628 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:19:15 -0400 Subject: [PATCH 51/69] feat(cli): relicario get with masking, --show, and zeroize-clipboard Secrets masked by default (audit M7). --show reveals plaintext. --copy writes to clipboard and spawns a detached 30s auto-clear thread holding a Zeroizing copy that wipes on drop (audit M6). --- crates/relicario-cli/src/main.rs | 114 ++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index dec59b0..a54b7cd 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -657,7 +657,119 @@ fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[& Ok(()) } -fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + println!("ID: {}", item.id.as_str()); + println!("Title: {}", item.title); + println!("Type: {:?}", item.r#type); + if let Some(g) = &item.group { println!("Group: {g}"); } + if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } + println!("Created: {}", crate::helpers::iso8601(item.created)); + println!("Modified: {}", crate::helpers::iso8601(item.modified)); + if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } + println!(); + + let primary_secret: Option> = match &item.core { + ItemCore::Login(l) => { + if let Some(u) = &l.username { println!("Username: {u}"); } + if let Some(u) = &l.url { println!("URL: {u}"); } + if let Some(p) = &l.password { Some(p.clone()) } else { None } + } + ItemCore::SecureNote(n) => { + if show { println!("Body:\n{}", n.body.as_str()); } + else { println!("Body: ********"); } + None + } + ItemCore::Identity(i) => { + if let Some(v) = &i.full_name { println!("Name: {v}"); } + if let Some(v) = &i.email { println!("Email: {v}"); } + if let Some(v) = &i.phone { println!("Phone: {v}"); } + if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); } + None + } + ItemCore::Card(c) => { + if let Some(h) = &c.holder { println!("Holder: {h}"); } + if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); } + println!("Kind: {:?}", c.kind); + c.number.clone() + } + ItemCore::Key(k) => { + if let Some(l) = &k.label { println!("Label: {l}"); } + if let Some(a) = &k.algorithm { println!("Algo: {a}"); } + if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); } + Some(k.key_material.clone()) + } + ItemCore::Document(d) => { + println!("Filename: {}", d.filename); + println!("MIME: {}", d.mime_type); + None + } + ItemCore::Totp(t) => { + if let Some(i) = &t.issuer { println!("Issuer: {i}"); } + if let Some(l) = &t.label { println!("Label: {l}"); } + println!("Period: {}s", t.config.period_seconds); + println!("Digits: {}", t.config.digits); + None + } + }; + + if let Some(secret) = primary_secret { + if show { + println!("Secret: {}", secret.as_str()); + } else { + println!("Secret: ******** (use --show to reveal, --copy to clipboard)"); + } + if copy { + copy_to_clipboard_then_clear(&secret)?; + eprintln!("Copied to clipboard (auto-clears in 30s)."); + } + } + + Ok(()) +} + +fn resolve_query<'a>( + manifest: &'a relicario_core::Manifest, + query: &str, +) -> Result<&'a relicario_core::ManifestEntry> { + if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) { + return Ok(entry); + } + let hits: Vec<_> = manifest.search(query); + match hits.len() { + 0 => anyhow::bail!("no item matches `{query}`"), + 1 => Ok(hits[0]), + _ => { + let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); + anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", ")) + } + } +} + +fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<()> { + use arboard::Clipboard; + let mut cb = Clipboard::new().context("failed to access clipboard")?; + cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?; + let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned()); + // Unconditional clear (audit M6): spawn a detached thread that waits 30s + // and then rewrites the clipboard with empty string. Even if the user + // copies something else in the interim, we still overwrite once. + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Ok(mut cb) = Clipboard::new() { + let _ = cb.set_text(String::new()); + drop(cleared_copy); // zeroize the detached copy + } + }); + Ok(()) +} fn cmd_list(_t: Option, _g: Option, _tag: Option, _trashed: bool) -> Result<()> { bail!("not yet implemented"); } fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } From 377d73355b1cebaf09dda7ec2962a6f149c7032c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:20:26 -0400 Subject: [PATCH 52/69] feat(cli): relicario list with --type/--group/--tag/--trashed filters Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/src/main.rs | 49 +++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index a54b7cd..f7a8bea 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -770,7 +770,54 @@ fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<( }); Ok(()) } -fn cmd_list(_t: Option, _g: Option, _tag: Option, _trashed: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_list( + type_filter: Option, + group_filter: Option, + tag_filter: Option, + trashed: bool, +) -> Result<()> { + use relicario_core::ItemType; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + + let parsed_type: Option = match type_filter.as_deref() { + None => None, + Some("login") => Some(ItemType::Login), + Some("secure_note") | Some("note") => Some(ItemType::SecureNote), + Some("identity") => Some(ItemType::Identity), + Some("card") => Some(ItemType::Card), + Some("key") => Some(ItemType::Key), + Some("document") => Some(ItemType::Document), + Some("totp") => Some(ItemType::Totp), + Some(other) => anyhow::bail!("unknown type filter: {other}"), + }; + + let mut entries: Vec<_> = manifest.items.values() + .filter(|e| { + if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() } + }) + .filter(|e| match parsed_type { + Some(t) => e.r#type == t, + None => true, + }) + .filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str()))) + .filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t))) + .collect(); + entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); + + if entries.is_empty() { + eprintln!("(no items match)"); + return Ok(()); + } + + println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE"); + for e in entries { + let fav = if e.favorite { " *" } else { "" }; + println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); + } + Ok(()) +} fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); } From 06c8903e2bdc2831e451dfc4a783d49d687f67b6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:22:45 -0400 Subject: [PATCH 53/69] =?UTF-8?q?feat(cli):=20relicario=20edit=20=E2=80=94?= =?UTF-8?q?=20interactive=20field=20updates=20+=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title/group/tags always optional. Per-type prompts for core secret fields (Login.password, Card.number, Key.material, SecureNote.body) push the old value to field_history via a synthetic core: FieldId so rotation is audit-traceable. --- crates/relicario-cli/src/main.rs | 141 ++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index f7a8bea..ee70fa3 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -818,7 +818,146 @@ fn cmd_list( } Ok(()) } -fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_edit(query: String) -> Result<()> { + use relicario_core::time::now_unix; + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + + eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", + item.title, item.id.as_str()); + + // Title + if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } + // Group + if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } + // Tags (comma-separated) + if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? { + item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + + // Core-specific fields. Only Login.password and Card.number/cvv/pin are + // history-tracked from the core path. + match &mut item.core { + ItemCore::Login(l) => { + if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); } + if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? { + l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?); + } + if prompt_yesno("Change password?")? { + let old = l.password.clone(); + let new_pw = Zeroizing::new(rpassword::prompt_password("New password: ")?); + l.password = Some(new_pw); + if let Some(old_pw) = old { + push_history(&mut item.field_history, "login_password", + Zeroizing::new(old_pw.as_str().to_string())); + } + } + } + ItemCore::SecureNote(n) => { + if prompt_yesno("Edit body?")? { + let old = n.body.clone(); + eprintln!("Enter new body; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + n.body = Zeroizing::new(s); + push_history(&mut item.field_history, "secure_note_body", + Zeroizing::new(old.as_str().to_string())); + } + } + ItemCore::Identity(i) => { + if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); } + if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); } + if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); } + } + ItemCore::Card(c) => { + if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); } + if prompt_yesno("Change card number?")? { + let old = c.number.clone(); + c.number = Some(Zeroizing::new(rpassword::prompt_password("New number: ")?)); + if let Some(o) = old { + push_history(&mut item.field_history, "card_number", + Zeroizing::new(o.as_str().to_string())); + } + } + } + ItemCore::Key(k) => { + if prompt_yesno("Replace key material?")? { + eprintln!("Paste new key material; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + let old = k.key_material.clone(); + k.key_material = Zeroizing::new(s); + push_history(&mut item.field_history, "key_material", + Zeroizing::new(old.as_str().to_string())); + } + } + ItemCore::Document(_) => { + eprintln!("Document items: use `relicario attach` / `relicario extract` instead."); + } + ItemCore::Totp(_) => { + eprintln!("TOTP rotation not yet implemented — delete and re-add for now."); + } + } + + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Updated {}", item.id.as_str()); + Ok(()) +} + +fn prompt_keep(label: &str, current: &str) -> Result> { + eprint!("{label} [{current}]: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result> { + let display = current.unwrap_or("(none)"); + eprint!("{label} [{display}]: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +fn prompt_yesno(label: &str) -> Result { + eprint!("{label} [y/N] "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes")) +} + +fn push_history( + history: &mut std::collections::HashMap>, + synthetic_key: &str, + old_value: zeroize::Zeroizing, +) { + use relicario_core::item::FieldHistoryEntry; + use relicario_core::time::now_unix; + // Synthetic FieldId for core-level fields — stable per-item (prefixed so + // custom-field UUIDs can't collide). + let fid = relicario_core::FieldId(format!("core:{synthetic_key}")); + history.entry(fid).or_default().push(FieldHistoryEntry { + value: old_value, + replaced_at: now_unix(), + }); +} fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); } From cc279bac0b168751b9608a91ef699397c1c21aab Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:24:32 -0400 Subject: [PATCH 54/69] =?UTF-8?q?feat(cli):=20trash=20ops=20=E2=80=94=20rm?= =?UTF-8?q?=20/=20restore=20/=20purge=20/=20trash=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-delete sets trashed_at via Item::soft_delete; restore clears it. Purge deletes item + attachment dir and removes manifest entry. Trash empty scans for items past settings.trash_retention. --- crates/relicario-cli/src/main.rs | 103 +++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index ee70fa3..370c378 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -958,10 +958,105 @@ fn push_history( replaced_at: now_unix(), }); } -fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } -fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); } -fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); } -fn cmd_trash(_action: TrashAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_rm(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + item.soft_delete(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Moved to trash: {}", item.title); + Ok(()) +} + +fn cmd_restore(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + item.restore(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Restored: {}", item.title); + Ok(()) +} + +fn cmd_purge(query: String) -> Result<()> { + use std::fs; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let title = entry.title.clone(); + let _ = entry; + + // Remove the item file, its attachments directory, and drop the manifest entry. + let item_path = vault.item_path(&id); + if item_path.exists() { fs::remove_file(&item_path)?; } + let att_dir = vault.root().join("attachments").join(id.as_str()); + if att_dir.exists() { fs::remove_dir_all(&att_dir)?; } + manifest.remove(&id); + vault.save_manifest(&manifest)?; + + // `git rm -rf --ignore-unmatch` stages the deletions. Then add manifest and commit. + let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch", + &format!("items/{}.enc", id.as_str()), + &format!("attachments/{}", id.as_str()), + ]).status()?; + let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; + if !status.success() { anyhow::bail!("git add manifest.enc failed"); } + let status = crate::helpers::git_command(vault.root(), + &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + eprintln!("Purged: {title}"); + Ok(()) +} + +fn cmd_trash(action: TrashAction) -> Result<()> { + match action { + TrashAction::List => cmd_list(None, None, None, true), + TrashAction::Empty => cmd_trash_empty(), + } +} + +fn cmd_trash_empty() -> Result<()> { + use relicario_core::time::now_unix; + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let settings = vault.load_settings()?; + let now = now_unix(); + + let purgeable: Vec<_> = manifest.items.values() + .filter(|e| match e.trashed_at { + Some(t) => settings.trash_retention.should_purge(t, now), + None => false, + }) + .map(|e| (e.id.clone(), e.title.clone())) + .collect(); + + if purgeable.is_empty() { + eprintln!("nothing past retention window"); + return Ok(()); + } + + for (id, title) in purgeable { + cmd_purge(id.as_str().to_string())?; + eprintln!(" purged {title}"); + } + Ok(()) +} fn cmd_attach(_q: String, _file: PathBuf) -> Result<()> { bail!("not yet implemented"); } fn cmd_attachments(_q: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_extract(_q: String, _aid: String, _out: Option) -> Result<()> { bail!("not yet implemented"); } From b5015b3e9b8c2cc6511bf126ba1b6cac4c9daab1 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:25:57 -0400 Subject: [PATCH 55/69] fix(cli): trash empty unlocks vault once, not per item Extracted purge_item helper so cmd_trash_empty loops over it without re-prompting for passphrase per item. Single git commit per trash empty summarizing the count. Caught in Task 12 review. --- crates/relicario-cli/src/main.rs | 55 ++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 370c378..2a27f2a 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -992,9 +992,32 @@ fn cmd_restore(query: String) -> Result<()> { Ok(()) } -fn cmd_purge(query: String) -> Result<()> { +/// Inner purge: assumes vault is already unlocked and manifest is loaded. +/// Caller is responsible for saving the manifest and committing afterwards. +fn purge_item( + vault: &crate::session::UnlockedVault, + manifest: &mut relicario_core::Manifest, + id: &relicario_core::ItemId, + title: &str, +) -> Result<()> { use std::fs; + let item_path = vault.item_path(id); + if item_path.exists() { fs::remove_file(&item_path)?; } + let att_dir = vault.root().join("attachments").join(id.as_str()); + if att_dir.exists() { fs::remove_dir_all(&att_dir)?; } + manifest.remove(id); + + let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch", + &format!("items/{}.enc", id.as_str()), + &format!("attachments/{}", id.as_str()), + ]).status()?; + // Note: caller adds+commits manifest.enc after processing all purges. + eprintln!("Purged: {title}"); + Ok(()) +} + +fn cmd_purge(query: String) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; @@ -1002,25 +1025,14 @@ fn cmd_purge(query: String) -> Result<()> { let title = entry.title.clone(); let _ = entry; - // Remove the item file, its attachments directory, and drop the manifest entry. - let item_path = vault.item_path(&id); - if item_path.exists() { fs::remove_file(&item_path)?; } - let att_dir = vault.root().join("attachments").join(id.as_str()); - if att_dir.exists() { fs::remove_dir_all(&att_dir)?; } - manifest.remove(&id); + purge_item(&vault, &mut manifest, &id, &title)?; vault.save_manifest(&manifest)?; - // `git rm -rf --ignore-unmatch` stages the deletions. Then add manifest and commit. - let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch", - &format!("items/{}.enc", id.as_str()), - &format!("attachments/{}", id.as_str()), - ]).status()?; let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; if !status.success() { anyhow::bail!("git add manifest.enc failed"); } let status = crate::helpers::git_command(vault.root(), &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?; if !status.success() { anyhow::bail!("git commit failed"); } - eprintln!("Purged: {title}"); Ok(()) } @@ -1033,8 +1045,9 @@ fn cmd_trash(action: TrashAction) -> Result<()> { fn cmd_trash_empty() -> Result<()> { use relicario_core::time::now_unix; + let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; + let mut manifest = vault.load_manifest()?; let settings = vault.load_settings()?; let now = now_unix(); @@ -1051,10 +1064,20 @@ fn cmd_trash_empty() -> Result<()> { return Ok(()); } + let mut purged_titles = Vec::new(); for (id, title) in purgeable { - cmd_purge(id.as_str().to_string())?; - eprintln!(" purged {title}"); + purge_item(&vault, &mut manifest, &id, &title)?; + purged_titles.push(title); } + + vault.save_manifest(&manifest)?; + let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; + if !status.success() { anyhow::bail!("git add manifest.enc failed"); } + let status = crate::helpers::git_command(vault.root(), + &["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + + eprintln!("Emptied trash: {} item(s)", purged_titles.len()); Ok(()) } fn cmd_attach(_q: String, _file: PathBuf) -> Result<()> { bail!("not yet implemented"); } From cbd1dbd7067d0a3343c62ccd715b8905aded13de Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:27:13 -0400 Subject: [PATCH 56/69] =?UTF-8?q?feat(cli):=20attachment=20ops=20=E2=80=94?= =?UTF-8?q?=20attach=20/=20attachments=20/=20extract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Respects AttachmentCaps from settings.enc; content-addressed aid comes from core::encrypt_attachment. --- crates/relicario-cli/src/main.rs | 96 +++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 2a27f2a..9cd29a4 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1080,9 +1080,99 @@ fn cmd_trash_empty() -> Result<()> { eprintln!("Emptied trash: {} item(s)", purged_titles.len()); Ok(()) } -fn cmd_attach(_q: String, _file: PathBuf) -> Result<()> { bail!("not yet implemented"); } -fn cmd_attachments(_q: String) -> Result<()> { bail!("not yet implemented"); } -fn cmd_extract(_q: String, _aid: String, _out: Option) -> Result<()> { bail!("not yet implemented"); } +fn cmd_attach(query: String, file: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::{encrypt_attachment, AttachmentRef}; + use relicario_core::time::now_unix; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let _ = entry; + let mut item = vault.load_item(&id)?; + let settings = vault.load_settings()?; + let caps = settings.attachment_caps; + + if item.attachments.len() as u32 >= caps.per_item_max_count { + anyhow::bail!("item already has {} attachments (max {})", + item.attachments.len(), caps.per_item_max_count); + } + + let bytes = fs::read(&file) + .with_context(|| format!("failed to read {}", file.display()))?; + let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?; + + let filename = file.file_name() + .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))? + .to_string_lossy() + .into_owned(); + let mime_type = guess_mime(&filename); + let aref = AttachmentRef { + id: enc.id.clone(), + filename, + mime_type, + size: bytes.len() as u64, + created: now_unix(), + }; + + let att_dir = vault.root().join("attachments").join(item.id.as_str()); + fs::create_dir_all(&att_dir)?; + fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?; + + item.attachments.push(aref); + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + + let paths = [ + format!("items/{}.enc", item.id.as_str()), + "manifest.enc".into(), + format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()), + ]; + let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + commit_paths(&vault, &format!("attach: {} → {} ({})", + file.display(), item.title, item.id.as_str()), &path_refs)?; + eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str()); + Ok(()) +} + +fn cmd_attachments(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } + println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME"); + for a in &item.attachments { + println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); + } + Ok(()) +} + +fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { + use std::fs; + use relicario_core::decrypt_attachment; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + let aref = item.attachments.iter().find(|a| a.id.as_str() == aid) + .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; + let path = vault.root().join("attachments").join(item.id.as_str()) + .join(format!("{}.enc", aid)); + let bytes = fs::read(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let plaintext = decrypt_attachment(&bytes, vault.key())?; + let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename)); + fs::write(&out_path, plaintext.as_slice()) + .with_context(|| format!("failed to write {}", out_path.display()))?; + eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); + Ok(()) +} fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result<()> { bail!("not yet implemented"); } fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); } fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } From a6bad4bb3ee1f738b2a8cabe809f0d47f474f306 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:30:56 -0400 Subject: [PATCH 57/69] feat(cli): relicario generate delegates to core (audit H6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI no longer has its own charset-sampling path — uses the CSPRNG generate_password / generate_passphrase in relicario-core, which use rand::distributions::Uniform internally. --- crates/relicario-cli/src/main.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 9cd29a4..5dcb728 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1173,7 +1173,34 @@ fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); Ok(()) } -fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator: String) -> Result<()> { + use relicario_core::{ + generate_passphrase, generate_password, Capitalization, CharClasses, + GeneratorRequest, SymbolCharset, + }; + + let output = if bip39 { + generate_passphrase(&GeneratorRequest::Bip39 { + word_count: words, + separator, + capitalization: Capitalization::Lower, + })? + } else { + let symbol_charset = match symbols.as_str() { + "safe" => SymbolCharset::SafeOnly, + "extended" => SymbolCharset::Extended, + other => SymbolCharset::Custom(other.to_string()), + }; + generate_password(&GeneratorRequest::Random { + length, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset, + })? + }; + + println!("{}", output.as_str()); + Ok(()) +} fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); } fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); } From 10f249d95e1c28ecc5dd3880d942d1cb8e7407d3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:31:27 -0400 Subject: [PATCH 58/69] feat(cli): relicario settings show / trash-retention / history-retention / attachment-cap --- crates/relicario-cli/src/main.rs | 43 +++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 5dcb728..6ca47e6 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1201,7 +1201,48 @@ fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator println!("{}", output.as_str()); Ok(()) } -fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_settings(action: SettingsAction) -> Result<()> { + use relicario_core::{HistoryRetention, TrashRetention}; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut settings = vault.load_settings()?; + + match action { + SettingsAction::Show => { + println!("{}", serde_json::to_string_pretty(&settings)?); + return Ok(()); + } + SettingsAction::TrashRetention { days, forever } => { + settings.trash_retention = match (days, forever) { + (Some(d), false) => TrashRetention::Days(d), + (None, true) => TrashRetention::Forever, + _ => anyhow::bail!("specify exactly one of --days or --forever"), + }; + } + SettingsAction::HistoryRetention { last_n, days, forever } => { + settings.field_history_retention = match (last_n, days, forever) { + (Some(n), None, false) => HistoryRetention::LastN(n), + (None, Some(d), false) => HistoryRetention::Days(d), + (None, None, true) => HistoryRetention::Forever, + _ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"), + }; + } + SettingsAction::AttachmentCap { + per_attachment_max_bytes, per_item_max_count, + per_vault_soft_cap_bytes, per_vault_hard_cap_bytes, + } => { + if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; } + if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; } + if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; } + if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; } + } + } + + vault.save_settings(&settings)?; + commit_paths(&vault, "settings: update", &["settings.enc"])?; + eprintln!("Settings updated."); + Ok(()) +} fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); } From a3871ac890348fb449c8d0e1d2944cf9c6a9444b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:31:45 -0400 Subject: [PATCH 59/69] =?UTF-8?q?feat(cli):=20relicario=20sync=20=E2=80=94?= =?UTF-8?q?=20pull=20--rebase=20then=20push=20via=20hardened=20git?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/relicario-cli/src/main.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 6ca47e6..0495954 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1243,7 +1243,15 @@ fn cmd_settings(action: SettingsAction) -> Result<()> { eprintln!("Settings updated."); Ok(()) } -fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } +fn cmd_sync() -> Result<()> { + let root = crate::helpers::vault_dir()?; + let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?; + if !pull.success() { anyhow::bail!("git pull --rebase failed"); } + let push = crate::helpers::git_command(&root, &["push"]).status()?; + if !push.success() { anyhow::bail!("git push failed"); } + eprintln!("Sync complete."); + Ok(()) +} fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); } #[derive(serde::Serialize)] From 8c315654aea53ccec93919febd1f9670858a0423 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:32:09 -0400 Subject: [PATCH 60/69] feat(cli): device add / list / revoke rewired to hardened git --- crates/relicario-cli/src/main.rs | 72 +++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 0495954..2669307 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1252,7 +1252,77 @@ fn cmd_sync() -> Result<()> { eprintln!("Sync complete."); Ok(()) } -fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_device(action: DeviceAction) -> Result<()> { + use std::fs; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + + let root = crate::helpers::vault_dir()?; + let devices_path = root.join(".relicario").join("devices.json"); + + #[derive(serde::Serialize, serde::Deserialize)] + struct DeviceEntry { name: String, public_key: String } + + match action { + DeviceAction::Add { name } => { + let mut existing: Vec = + serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); + if existing.iter().any(|d| d.name == name) { + anyhow::bail!("device `{name}` already exists"); + } + let signing = SigningKey::generate(&mut OsRng); + let verifying = signing.verifying_key(); + let pubkey_hex = hex::encode(verifying.to_bytes()); + + existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() }); + fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?; + + let cfg_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("no config dir"))? + .join("relicario").join("devices"); + fs::create_dir_all(&cfg_dir)?; + let key_path = cfg_dir.join(format!("{name}.key")); + fs::write(&key_path, signing.to_bytes())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; + } + + let status = crate::helpers::git_command(&root, + &["add", ".relicario/devices.json"]).status()?; + if !status.success() { anyhow::bail!("git add failed"); } + let status = crate::helpers::git_command(&root, + &["commit", "-m", &format!("device: add {name}")]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + eprintln!("Added device `{name}` (pubkey: {pubkey_hex})"); + } + DeviceAction::List => { + let existing: Vec = + serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); + if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); } + for d in existing { + println!("{:<20} {}", d.name, d.public_key); + } + } + DeviceAction::Revoke { name } => { + let mut existing: Vec = + serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); + let before = existing.len(); + existing.retain(|d| d.name != name); + if existing.len() == before { anyhow::bail!("device `{name}` not found"); } + fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?; + let status = crate::helpers::git_command(&root, + &["add", ".relicario/devices.json"]).status()?; + if !status.success() { anyhow::bail!("git add failed"); } + let status = crate::helpers::git_command(&root, + &["commit", "-m", &format!("device: revoke {name}")]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + eprintln!("Revoked device `{name}`"); + } + } + Ok(()) +} #[derive(serde::Serialize)] struct ParamsFile { From f3ce76d9fb6d98b20131aca7e2821bf195dbcf73 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:34:50 -0400 Subject: [PATCH 61/69] feat(wasm): opaque SessionHandle bridge with unlock/lock Master key never leaves WASM linear memory. Held in Zeroizing<[u8;32]> inside a thread_local HashMap keyed by u32. lock() removes + zeroizes. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-wasm/Cargo.toml | 7 +- crates/relicario-wasm/src/lib.rs | 389 ++++----------------------- crates/relicario-wasm/src/session.rs | 41 +++ 3 files changed, 93 insertions(+), 344 deletions(-) create mode 100644 crates/relicario-wasm/src/session.rs diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index 0cf6eb9..2f04cdc 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -10,11 +10,10 @@ crate-type = ["cdylib", "rlib"] [dependencies] relicario-core = { path = "../relicario-core" } wasm-bindgen = "0.2" -js-sys = "0.3" +serde-wasm-bindgen = "0.6" serde_json = "1" -hmac = "0.12" -sha1 = "0.10" -data-encoding = "2" +serde = { version = "1", features = ["derive"] } +zeroize = "1" getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 8a6b767..51539b9 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -1,364 +1,73 @@ -//! WASM bindings for the relicario password manager. +//! WASM bindings for relicario. //! -//! This crate wraps [`relicario_core`] for use in a Chrome MV3 browser extension via -//! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from -//! JavaScript after loading the compiled `.wasm` module. -//! -//! All crypto operations run entirely in the browser -- the extension never sends -//! secrets to any server. The TOTP function lets the extension generate live 6-digit -//! authenticator codes without a separate authenticator app. -//! -//! ## Design notes -//! -//! - Functions accept and return `Vec`, `&[u8]`, and `String` -- wasm-bindgen -//! handles the JS ↔ Rust marshalling automatically (typed arrays for bytes, strings -//! for JSON). -//! - Errors are mapped to `JsValue` strings so they surface as thrown exceptions in JS. -//! - `generate_password` and `generate_entry_id` use `js_sys::Math::random()` because -//! `OsRng`/`getrandom` requires special WASM configuration. `Math.random()` is -//! sufficient for these non-security-critical operations (password character selection -//! and identifier generation). +//! The bridge exposes an opaque `SessionHandle` API: the master key is held +//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and +//! looked up per call via a u32 handle. JS cannot read key bytes. + +mod session; use wasm_bindgen::prelude::*; -use relicario_core::crypto::{self, KdfParams}; -use relicario_core::entry::Entry; -use relicario_core::vault; -use relicario_core::imgsecret; +use relicario_core::{derive_master_key, imgsecret, KdfParams}; -use hmac::{Hmac, Mac}; -use sha1::Sha1; - -/// Derive a 256-bit master key from a passphrase, image secret, salt, and KDF parameters. -/// -/// The `params_json` argument is a JSON object with fields `argon2_m`, `argon2_t`, -/// and `argon2_p` (matching [`KdfParams`]). Example: -/// -/// ```json -/// {"argon2_m": 65536, "argon2_t": 3, "argon2_p": 4} -/// ``` -/// -/// Returns a 32-byte `Uint8Array` in JavaScript. +/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS. #[wasm_bindgen] -pub fn derive_master_key( +pub struct SessionHandle(u32); + +#[wasm_bindgen] +impl SessionHandle { + #[wasm_bindgen(getter)] + pub fn value(&self) -> u32 { self.0 } +} + +#[wasm_bindgen] +pub fn unlock( passphrase: &str, - image_secret: &[u8], + image_bytes: &[u8], salt: &[u8], params_json: &str, -) -> Result, JsValue> { - let params: KdfParams = - serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?; - - let image_secret: &[u8; 32] = image_secret - .try_into() - .map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?; - let salt: &[u8; 32] = salt - .try_into() - .map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?; - - let key = crypto::derive_master_key(passphrase.as_bytes(), image_secret, salt, ¶ms) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - Ok(key.to_vec()) +) -> Result { + let params: KdfParams = serde_json::from_str(params_json) + .map_err(|e| JsError::new(&format!("params: {e}")))?; + let image_secret = imgsecret::extract(image_bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + let salt_arr: &[u8; 32] = salt.try_into() + .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms) + .map_err(|e| JsError::new(&e.to_string()))?; + let handle = session::insert(master_key); + Ok(SessionHandle(handle)) } -/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305. -/// -/// Returns the ciphertext as a `Uint8Array` in the format: -/// `version(1) || nonce(24) || ciphertext+tag`. #[wasm_bindgen] -pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - crypto::encrypt(key, plaintext).map_err(|e| JsValue::from_str(&e.to_string())) +pub fn lock(handle: &SessionHandle) -> bool { + session::remove(handle.0) } -/// Decrypt a ciphertext blob produced by [`encrypt`], returning the original plaintext. -/// -/// Returns the plaintext as a `Uint8Array`. Throws if the key is wrong or the data -/// has been tampered with. -#[wasm_bindgen] -pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - crypto::decrypt(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Extract the 32-byte steganographic secret from a JPEG image. -/// -/// Returns a 32-byte `Uint8Array` containing the embedded secret. -/// Throws if the image is not a valid JPEG or the secret cannot be recovered. -#[wasm_bindgen] -pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result, JsValue> { - let secret = - imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?; - Ok(secret.to_vec()) -} - -/// Embed a 256-bit secret into a carrier JPEG image. -#[wasm_bindgen] -pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result, JsValue> { - let secret: [u8; 32] = secret - .try_into() - .map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?; - relicario_core::imgsecret::embed(carrier_jpeg, &secret) - .map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Encrypt an [`Entry`] (given as a JSON string) under the master key. -/// -/// The `entry_json` must deserialize into an [`Entry`] struct. Returns the -/// ciphertext as a `Uint8Array`. -#[wasm_bindgen] -pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let entry: Entry = - serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?; - vault::encrypt_entry(key, &entry).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Decrypt an entry ciphertext blob and return the entry as a JSON string. -/// -/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed. -#[wasm_bindgen] -pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let entry = - vault::decrypt_entry(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?; - serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Encrypt a [`Manifest`] (given as a JSON string) under the master key. -/// -/// Returns the ciphertext as a `Uint8Array`. -#[wasm_bindgen] -pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result, JsValue> { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let manifest: relicario_core::entry::Manifest = - serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?; - vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Decrypt a manifest ciphertext blob and return the manifest as a JSON string. -/// -/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed. -#[wasm_bindgen] -pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result { - let key: &[u8; 32] = key - .try_into() - .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; - let manifest = vault::decrypt_manifest(key, ciphertext) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Generate a 6-digit TOTP code per RFC 6238. -/// -/// # Arguments -/// -/// - `secret_base32`: the shared secret encoded in base32 (with or without padding). -/// - `timestamp_secs`: the current Unix timestamp in seconds. -/// -/// # Algorithm -/// -/// 1. Decode the base32 secret. -/// 2. Compute the time step: `T = timestamp_secs / 30`. -/// 3. Compute `HMAC-SHA1(secret, T as big-endian u64)`. -/// 4. Dynamic truncation: extract a 4-byte segment from the HMAC output at an -/// offset determined by the last nibble. -/// 5. Mask the high bit, take modulo 10^6, and zero-pad to 6 digits. -/// -/// Returns a 6-character string like `"287082"`. -#[wasm_bindgen] -pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result { - generate_totp_inner(secret_base32, timestamp_secs) - .map_err(|e| JsValue::from_str(&e.to_string())) -} - -/// Inner TOTP implementation that returns a standard Result for testability -/// (avoids depending on JsValue in native tests). -fn generate_totp_inner( - secret_base32: &str, - timestamp_secs: u64, -) -> std::result::Result { - // Normalize: strip whitespace, uppercase, remove padding for lenient decode, - // then re-pad to a multiple of 8 for strict base32. - let cleaned: String = secret_base32 - .chars() - .filter(|c| !c.is_whitespace()) - .collect::() - .to_uppercase() - .trim_end_matches('=') - .to_string(); - - // Re-pad to a multiple of 8 characters (base32 requirement). - let padded = { - let remainder = cleaned.len() % 8; - if remainder == 0 { - cleaned - } else { - let pad_count = 8 - remainder; - format!("{}{}", cleaned, "=".repeat(pad_count)) - } - }; - - let secret = data_encoding::BASE32 - .decode(padded.as_bytes()) - .map_err(|e| format!("invalid base32 secret: {}", e))?; - - // Time step: T = floor(timestamp / 30) - let time_step = timestamp_secs / 30; - - // HMAC-SHA1(secret, time_step as big-endian u64) - type HmacSha1 = Hmac; - let mut mac = - HmacSha1::new_from_slice(&secret).map_err(|e| format!("HMAC init failed: {}", e))?; - mac.update(&time_step.to_be_bytes()); - let result = mac.finalize().into_bytes(); - - // Dynamic truncation per RFC 4226 section 5.4 - let offset = (result[19] & 0x0F) as usize; - let code = ((result[offset] as u32 & 0x7F) << 24) - | ((result[offset + 1] as u32) << 16) - | ((result[offset + 2] as u32) << 8) - | (result[offset + 3] as u32); - - // 6-digit code, zero-padded - Ok(format!("{:06}", code % 1_000_000)) -} - -/// Generate a random password of the given length. -/// -/// Uses `js_sys::Math::random()` for randomness (not cryptographically secure, -/// but sufficient for password character selection). The character set includes -/// uppercase, lowercase, digits, and common symbols. -/// -/// This function is only available in WASM -- it will panic in native builds -/// because `js_sys::Math::random()` requires a JS runtime. -#[wasm_bindgen] -pub fn generate_password(length: u32) -> String { - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?"; - - (0..length) - .map(|_| { - let idx = (js_sys::Math::random() * CHARSET.len() as f64) as usize; - CHARSET[idx % CHARSET.len()] as char - }) - .collect() -} - -/// Generate a random 8-character hex string for use as an entry ID. -/// -/// Uses `js_sys::Math::random()` for randomness. Entry IDs are not -/// security-sensitive -- they are just opaque identifiers. -/// -/// This function is only available in WASM -- it will panic in native builds -/// because `js_sys::Math::random()` requires a JS runtime. -#[wasm_bindgen] -pub fn generate_entry_id() -> String { - (0..4) - .map(|_| { - let byte = (js_sys::Math::random() * 256.0) as u8; - format!("{:02x}", byte) - }) - .collect() -} +// Subsequent wasm_bindgen fns added in Tasks 19-21. #[cfg(test)] -mod tests { +mod session_tests { use super::*; + use zeroize::Zeroizing; #[test] - fn totp_rfc6238_test_vector() { - // secret = "12345678901234567890" ASCII, time = 59, expected = "287082" - let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890"); - let result = generate_totp_inner(&secret_b32, 59).unwrap(); - assert_eq!(result, "287082"); + fn insert_then_remove_clears_entry() { + session::clear(); + let h = session::insert(Zeroizing::new([0x11u8; 32])); + assert_ne!(h, 0); + assert!(session::remove(h)); + assert!(!session::remove(h)); // second remove false } #[test] - fn totp_rfc6238_test_vector_2() { - // time = 1111111109, expected = "081804" - let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890"); - let result = generate_totp_inner(&secret_b32, 1111111109).unwrap(); - assert_eq!(result, "081804"); - } - - #[test] - fn totp_rfc6238_test_vector_3() { - // time = 1234567890, expected = "005924" - let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890"); - let result = generate_totp_inner(&secret_b32, 1234567890).unwrap(); - assert_eq!(result, "005924"); - } - - #[test] - fn totp_invalid_base32_fails() { - let result = generate_totp_inner("not-valid-base32!!!", 1000); - assert!(result.is_err()); - } - - #[test] - fn derive_key_via_wasm_wrapper() { - let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; - let key = - derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap(); - assert_eq!(key.len(), 32); - let key2 = - derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap(); - assert_eq!(key, key2); - } - - #[test] - fn encrypt_decrypt_via_wasm_wrapper() { - let key = [0xABu8; 32]; - let ciphertext = encrypt(b"hello wasm", &key).unwrap(); - let decrypted = decrypt(&ciphertext, &key).unwrap(); - assert_eq!(decrypted, b"hello wasm"); - } - - #[test] - fn embed_then_extract_round_trip() { - use image::codecs::jpeg::JpegEncoder; - use image::{ImageBuffer, ImageEncoder, Rgb}; - - let img = ImageBuffer::from_fn(400, 300, |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 jpeg_buf = Vec::new(); - let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92); - encoder.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8).unwrap(); - - let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, - 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, - 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, - 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8]; - - let stego = embed_image_secret(&jpeg_buf, &secret).unwrap(); - let extracted = extract_image_secret(&stego).unwrap(); - assert_eq!(extracted, secret); - } - - #[test] - fn encrypt_entry_decrypt_entry_round_trip() { - let key = [0xABu8; 32]; - let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#; - let ciphertext = encrypt_entry(entry_json, &key).unwrap(); - let result = decrypt_entry(&ciphertext, &key).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); - assert_eq!(parsed["name"], "Test"); - assert_eq!(parsed["password"], "secret"); + fn with_yields_key_only_while_session_lives() { + session::clear(); + let h = session::insert(Zeroizing::new([0x22u8; 32])); + let byte = session::with(h, |k| k[0]); + assert_eq!(byte, Some(0x22)); + session::remove(h); + let byte = session::with(h, |k| k[0]); + assert_eq!(byte, None); } } diff --git a/crates/relicario-wasm/src/session.rs b/crates/relicario-wasm/src/session.rs new file mode 100644 index 0000000..6b553ad --- /dev/null +++ b/crates/relicario-wasm/src/session.rs @@ -0,0 +1,41 @@ +//! Opaque session-handle bridge. The master key never leaves WASM linear +//! memory; JS receives only a u32 handle that it passes back on every +//! subsequent call. + +use std::cell::RefCell; +use std::collections::HashMap; +use zeroize::Zeroizing; + +thread_local! { + static SESSIONS: RefCell>> = RefCell::new(HashMap::new()); + static NEXT_HANDLE: RefCell = const { RefCell::new(1) }; +} + +pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 { + let handle = NEXT_HANDLE.with(|n| { + let mut n = n.borrow_mut(); + let h = *n; + *n = n.wrapping_add(1); + if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle + h + }); + SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); }); + handle +} + +pub fn with(handle: u32, f: F) -> Option +where + F: FnOnce(&Zeroizing<[u8; 32]>) -> R, +{ + SESSIONS.with(|s| s.borrow().get(&handle).map(f)) +} + +pub fn remove(handle: u32) -> bool { + SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some()) +} + +/// For tests only — empty the table and wipe all sessions. +#[cfg(test)] +pub fn clear() { + SESSIONS.with(|s| s.borrow_mut().clear()); +} From fac2e49cf133e4de10f5b4ea91a9e179dce1a28e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:37:50 -0400 Subject: [PATCH 62/69] feat(wasm): manifest / item / settings encrypt+decrypt via SessionHandle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds six #[wasm_bindgen] functions (manifest_encrypt/decrypt, item_encrypt/decrypt, settings_encrypt/decrypt) plus a native round-trip test that verifies encrypt→core_decrypt and nonce uniqueness without calling js-sys (serde_wasm_bindgen::from_value is wasm32-only; documented in test comment). --- crates/relicario-wasm/src/lib.rs | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 51539b9..f3b5a2e 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -46,6 +46,75 @@ pub fn lock(handle: &SessionHandle) -> bool { // Subsequent wasm_bindgen fns added in Tasks 19-21. +use serde_wasm_bindgen::to_value; +use relicario_core::{ + decrypt_item, decrypt_manifest, decrypt_settings, + encrypt_item, encrypt_manifest, encrypt_settings, + Item, Manifest, VaultSettings, +}; + +fn need_key(handle: &SessionHandle) -> Result<(), JsError> { + if session::with(handle.0, |_| ()).is_some() { Ok(()) } + else { Err(JsError::new("invalid or locked session handle")) } +} + +#[wasm_bindgen] +pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + to_value(&out).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError> { + need_key(handle)?; + let m: Manifest = serde_json::from_str(manifest_json) + .map_err(|e| JsError::new(&format!("manifest json: {e}")))?; + session::with(handle.0, |k| encrypt_manifest(&m, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| decrypt_item(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + to_value(&out).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result, JsError> { + need_key(handle)?; + let item: Item = serde_json::from_str(item_json) + .map_err(|e| JsError::new(&format!("item json: {e}")))?; + session::with(handle.0, |k| encrypt_item(&item, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| decrypt_settings(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + to_value(&out).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result, JsError> { + need_key(handle)?; + let s: VaultSettings = serde_json::from_str(settings_json) + .map_err(|e| JsError::new(&format!("settings json: {e}")))?; + session::with(handle.0, |k| encrypt_settings(&s, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + #[cfg(test)] mod session_tests { use super::*; @@ -70,4 +139,22 @@ mod session_tests { let byte = session::with(h, |k| k[0]); assert_eq!(byte, None); } + + #[test] + fn manifest_round_trip_via_handle() { + use relicario_core::{Manifest, decrypt_manifest}; + session::clear(); + let h = session::insert(Zeroizing::new([0x55u8; 32])); + let handle = SessionHandle(h); + let key = Zeroizing::new([0x55u8; 32]); + let empty = Manifest::new(); + let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap(); + assert!(!bytes.is_empty()); + // Decrypt via core directly (avoids js-sys on native). + let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap(); + assert_eq!(parsed.items.len(), 0); + // Random nonces mean two encryptions of the same plaintext differ. + let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap(); + assert_ne!(bytes, bytes2, "nonces must differ"); + } } From 92b9e64ef9ec619a0e6a5cddd8be910cecf7a362 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:39:45 -0400 Subject: [PATCH 63/69] feat(wasm): attachment / generator / totp / imgsecret / id bridges Also ports TOTP RFC 6238 compute to relicario-core::item_types::totp so native + CLI + WASM share one implementation (audit H5: CSPRNG via core's Uniform-sampling generator). Adds hmac = "0.12" and sha1 = "0.10" to relicario-core deps to support HOTP/TOTP HMAC with Sha1/Sha256/Sha512. RFC 6238 test vector (t=59, SHA-1, 8 digits) passes: "94287082". --- crates/relicario-core/Cargo.toml | 2 + crates/relicario-core/src/item_types/mod.rs | 2 +- crates/relicario-core/src/item_types/totp.rs | 62 ++++++++++ crates/relicario-wasm/src/lib.rs | 117 +++++++++++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index e483ac4..37bbd26 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -12,6 +12,8 @@ argon2 = "0.5" chacha20poly1305 = "0.10" rand = "0.8" sha2 = "0.10" +sha1 = "0.10" +hmac = "0.12" ed25519-dalek = { version = "2", features = ["rand_core"] } image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/crates/relicario-core/src/item_types/mod.rs b/crates/relicario-core/src/item_types/mod.rs index 0dd9e61..74edc78 100644 --- a/crates/relicario-core/src/item_types/mod.rs +++ b/crates/relicario-core/src/item_types/mod.rs @@ -21,7 +21,7 @@ pub use identity::IdentityCore; pub use card::{CardCore, CardKind}; pub use key::KeyCore; pub use document::DocumentCore; -pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind}; +pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind, compute_totp_code}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 1b6c52f..58ce1f8 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -1,8 +1,13 @@ //! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login. +use hmac::{Hmac, Mac}; +use sha1::Sha1 as HmacSha1; +use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +use crate::error::{RelicarioError, Result}; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TotpCore { pub config: TotpConfig, @@ -55,6 +60,63 @@ impl Default for TotpKind { fn default() -> Self { TotpKind::Totp } } +/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp. +/// +/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`. +/// For HOTP: uses the `counter` carried in the variant. +pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result { + let counter = match config.kind { + TotpKind::Totp => now_unix_seconds / config.period_seconds as u64, + TotpKind::Hotp { counter } => counter, + TotpKind::Steam => now_unix_seconds / config.period_seconds as u64, + }; + let counter_bytes = counter.to_be_bytes(); + let hmac_out: Vec = match config.algorithm { + TotpAlgorithm::Sha1 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + TotpAlgorithm::Sha256 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + TotpAlgorithm::Sha512 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + }; + let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize; + let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24) + | ((hmac_out[offset + 1] as u32) << 16) + | ((hmac_out[offset + 2] as u32) << 8) + | (hmac_out[offset + 3] as u32); + let modulus = 10u32.pow(config.digits as u32); + Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize)) +} + +#[cfg(test)] +mod compute_tests { + use super::*; + + #[test] + fn rfc6238_sha1_vector_59() { + let cfg = TotpConfig { + secret: Zeroizing::new(b"12345678901234567890".to_vec()), + algorithm: TotpAlgorithm::Sha1, + digits: 8, + period_seconds: 30, + kind: TotpKind::Totp, + }; + assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082"); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f3b5a2e..03ce2ed 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -115,6 +115,123 @@ pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result, +} + +#[wasm_bindgen] +impl EncryptedAttachment { + #[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() } + #[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec { self.bytes.clone() } +} + +#[wasm_bindgen] +pub fn attachment_encrypt( + handle: &SessionHandle, + plaintext: &[u8], + max_bytes: u64, +) -> Result { + need_key(handle)?; + let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes }) +} + +#[wasm_bindgen] +pub fn attachment_decrypt( + handle: &SessionHandle, + encrypted: &[u8], +) -> Result, JsError> { + need_key(handle)?; + let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(plain.to_vec()) +} + +#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() } +#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() } + +use relicario_core::{ + generate_passphrase as core_generate_passphrase, + generate_password as core_generate_password, + rate_passphrase as core_rate_passphrase, + GeneratorRequest, +}; + +#[wasm_bindgen] +pub fn generate_password(request_json: &str) -> Result { + let req: GeneratorRequest = serde_json::from_str(request_json) + .map_err(|e| JsError::new(&format!("generator request: {e}")))?; + let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?; + Ok(out.as_str().to_owned()) +} + +#[wasm_bindgen] +pub fn generate_passphrase(request_json: &str) -> Result { + let req: GeneratorRequest = serde_json::from_str(request_json) + .map_err(|e| JsError::new(&format!("generator request: {e}")))?; + let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?; + Ok(out.as_str().to_owned()) +} + +#[wasm_bindgen] +pub fn rate_passphrase(p: &str) -> Result { + let est = core_rate_passphrase(p); + to_value(&serde_json::json!({ + "score": est.score, + "guesses_log10": est.guesses_log10, + })).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn extract_image_secret(image_bytes: &[u8]) -> Result, JsError> { + let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?; + Ok(s.to_vec()) +} + +#[wasm_bindgen] +pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result, JsError> { + let s: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string())) +} + +use relicario_core::item_types::{TotpConfig, compute_totp_code}; + +#[wasm_bindgen] +pub struct TotpCode { + code: String, + expires_at: u64, +} + +#[wasm_bindgen] +impl TotpCode { + #[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() } + #[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at } +} + +#[wasm_bindgen] +pub fn totp_compute( + config_json: &str, + now_unix_seconds: u64, +) -> Result { + let cfg: TotpConfig = serde_json::from_str(config_json) + .map_err(|e| JsError::new(&format!("totp config: {e}")))?; + let code = compute_totp_code(&cfg, now_unix_seconds) + .map_err(|e| JsError::new(&e.to_string()))?; + let period = cfg.period_seconds as u64; + let expires_at = ((now_unix_seconds / period) + 1) * period; + Ok(TotpCode { code, expires_at }) +} + #[cfg(test)] mod session_tests { use super::*; From b8afec3560c82cfa7334c83ffcae0809d3d8f388 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 17:41:41 -0400 Subject: [PATCH 64/69] feat(wasm): configure serde_wasm_bindgen for plain-object HashMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps serialize as JS objects, not Maps — what the extension popup expects. Also ships hand-written TS declarations for the bridge (consumed by Plan 1C). Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-wasm/src/lib.rs | 17 ++++--- extension/src/wasm.d.ts | 79 ++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 03ce2ed..3e8d604 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -46,7 +46,7 @@ pub fn lock(handle: &SessionHandle) -> bool { // Subsequent wasm_bindgen fns added in Tasks 19-21. -use serde_wasm_bindgen::to_value; +use serde_wasm_bindgen::Serializer; use relicario_core::{ decrypt_item, decrypt_manifest, decrypt_settings, encrypt_item, encrypt_manifest, encrypt_settings, @@ -58,13 +58,18 @@ fn need_key(handle: &SessionHandle) -> Result<(), JsError> { else { Err(JsError::new("invalid or locked session handle")) } } +fn js_value_for(v: &T) -> Result { + let ser = Serializer::new().serialize_maps_as_objects(true); + v.serialize(&ser).map_err(|e| JsError::new(&e.to_string())) +} + #[wasm_bindgen] pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { need_key(handle)?; let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k)) .unwrap() .map_err(|e| JsError::new(&e.to_string()))?; - to_value(&out).map_err(|e| JsError::new(&e.to_string())) + js_value_for(&out) } #[wasm_bindgen] @@ -83,7 +88,7 @@ pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result Result Result { #[wasm_bindgen] pub fn rate_passphrase(p: &str) -> Result { let est = core_rate_passphrase(p); - to_value(&serde_json::json!({ + js_value_for(&serde_json::json!({ "score": est.score, "guesses_log10": est.guesses_log10, - })).map_err(|e| JsError::new(&e.to_string())) + })) } #[wasm_bindgen] diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index e63ee64..5b2f5cb 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -1,25 +1,58 @@ -/// Type declarations for the relicario WASM module produced by wasm-pack. +// Thin TypeScript declarations for the relicario-wasm bindings. +// These are hand-written to mirror the #[wasm_bindgen] signatures in +// crates/relicario-wasm/src/lib.rs; keep them in sync manually. -// Ambient module declarations for the WASM glue code. -// The module specifier must exactly match what's used in import statements. - -declare module 'relicario-wasm' { - export default function init(input?: string | URL): Promise; - export function derive_master_key( - passphrase: string, - image_secret: Uint8Array, - salt: Uint8Array, - params_json: string, - ): Uint8Array; - export function encrypt(plaintext: Uint8Array, key: Uint8Array): Uint8Array; - export function decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array; - export function extract_image_secret(jpeg_bytes: Uint8Array): Uint8Array; - export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array; - export function encrypt_entry(entry_json: string, key: Uint8Array): Uint8Array; - export function decrypt_entry(ciphertext: Uint8Array, key: Uint8Array): string; - export function encrypt_manifest(manifest_json: string, key: Uint8Array): Uint8Array; - export function decrypt_manifest(ciphertext: Uint8Array, key: Uint8Array): string; - export function generate_totp(secret_base32: string, timestamp_secs: bigint): string; - export function generate_password(length: number): string; - export function generate_entry_id(): string; +export class SessionHandle { + readonly value: number; + free(): void; } + +export class EncryptedAttachment { + readonly aid: string; + readonly bytes: Uint8Array; + free(): void; +} + +export class TotpCode { + readonly code: string; + readonly expires_at: number; + free(): void; +} + +export function unlock( + passphrase: string, + image_bytes: Uint8Array, + salt: Uint8Array, + params_json: string, +): SessionHandle; + +export function lock(handle: SessionHandle): boolean; + +export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; +export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; +export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; +export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array; +export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; +export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array; + +export function attachment_encrypt( + handle: SessionHandle, + plaintext: Uint8Array, + max_bytes: bigint, +): EncryptedAttachment; +export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array; + +export function new_item_id(): string; +export function new_field_id(): string; + +export function generate_password(request_json: string): string; +export function generate_passphrase(request_json: string): string; +export function rate_passphrase(p: string): { score: number; guesses_log10: number }; + +export function extract_image_secret(image_bytes: Uint8Array): Uint8Array; +export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array; + +export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode; + +// Initializer (wasm-bindgen's default init function). +export default function init(module_or_path?: unknown): Promise; From 494eedbbb86c14ff808618d7279e8afcf85e8462 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 18:31:46 -0400 Subject: [PATCH 65/69] init: new relicario vault (format v2) --- .gitignore | 8 +------- .relicario/devices.json | 1 + .relicario/params.json | 11 +++++++++++ .relicario/salt | 1 + manifest.enc | Bin 0 -> 72 bytes settings.enc | Bin 0 -> 468 bytes 6 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 .relicario/devices.json create mode 100644 .relicario/params.json create mode 100644 .relicario/salt create mode 100644 manifest.enc create mode 100644 settings.enc diff --git a/.gitignore b/.gitignore index d8a40da..02e4099 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ -target/ -.superpowers/ -.worktrees/ -extension/node_modules/ -extension/dist/ -extension/dist-firefox/ -extension/wasm/ +ref.jpg diff --git a/.relicario/devices.json b/.relicario/devices.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.relicario/devices.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.relicario/params.json b/.relicario/params.json new file mode 100644 index 0000000..8676580 --- /dev/null +++ b/.relicario/params.json @@ -0,0 +1,11 @@ +{ + "format_version": 2, + "kdf": { + "algorithm": "argon2id-v0x13", + "argon2_m": 65536, + "argon2_t": 3, + "argon2_p": 4 + }, + "aead": "xchacha20poly1305", + "salt_path": ".relicario/salt" +} \ No newline at end of file diff --git a/.relicario/salt b/.relicario/salt new file mode 100644 index 0000000..4d3c892 --- /dev/null +++ b/.relicario/salt @@ -0,0 +1 @@ +48^՜\׏l$$. r \ No newline at end of file diff --git a/manifest.enc b/manifest.enc new file mode 100644 index 0000000000000000000000000000000000000000..b75b3330d6cbc0450c1ebf4dbcf80c392bb0ada5 GIT binary patch literal 72 zcmV-O0Jr}FgfaGH2s8c2dA)Db-8JDZNg}7xuGIM@YOZQSv@@+92mobX4?IkuT+|CHwG$COC literal 0 HcmV?d00001 diff --git a/settings.enc b/settings.enc new file mode 100644 index 0000000000000000000000000000000000000000..81983601a2d6ca4c7c44574972ee82174768e213 GIT binary patch literal 468 zcmV;_0W1Cj4`%p;j)44zmYq0tF3PyOb3#6ZNhxd!eq-gc)&Df5|FFclJ{F#C|L+ydxN*0!?oJ`0SpBj{xmhEVj-Cq0uWC8dP=#}8;exB zAB0XBiP8!era>Uwdr_t9|m#c$L!qlSWvy zps~H2EMY+suX#H9liyWM!`cAE8&EREpubg`JLa)$dRGa(UhypQzc?qA_&hD2GXN~r zeH?_KF|LNY8GT0fKYKMZFFX>8^Z{LSBPHJ^TNql4vaAo<1^7p05ML>^lMa)GqN&dUAwBs9QYI!LbfSsf8S}#WibzGYj z5Ng@7+9D-AaWf8G{))sPpCrp+*nZI7T@^+@sT8_YqDNz6~GpL zXN`?reG)-~OLwDI|D6frB<@=<#GtGOxjRcypkS? Date: Mon, 20 Apr 2026 18:32:45 -0400 Subject: [PATCH 66/69] test(cli): integration harness + basic flow tests Uses assert_cmd + tempfile to spin up a fresh vault per test. Covers init layout, add/list/get mask semantics, rm/restore/purge cycle, and generate smoke. Adds RELICARIO_TEST_PASSPHRASE env-var hatch in unlock_interactive and cmd_init so tests don't need a TTY. Also fixes read_params in session.rs to correctly parse the nested params.json format (kdf sub-object) rather than trying to deserialize the whole file as KdfParams. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 492 ++++++++++++++++++++-- crates/relicario-cli/Cargo.toml | 2 + crates/relicario-cli/src/main.rs | 14 +- crates/relicario-cli/src/session.rs | 22 +- crates/relicario-cli/tests/basic_flows.rs | 136 ++++++ crates/relicario-cli/tests/common/mod.rs | 117 +++++ 6 files changed, 741 insertions(+), 42 deletions(-) create mode 100644 crates/relicario-cli/tests/basic_flows.rs create mode 100644 crates/relicario-cli/tests/common/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 68b9fa6..a1baf86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -212,6 +227,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -455,6 +481,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -539,6 +571,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -566,6 +604,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fax" version = "0.2.6" @@ -617,6 +661,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -683,6 +742,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "half" version = "2.7.1" @@ -694,6 +766,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -830,6 +917,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -867,6 +960,18 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + [[package]] name = "inout" version = "0.1.4" @@ -915,6 +1020,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.184" @@ -999,6 +1110,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1231,6 +1348,46 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1261,6 +1418,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1288,7 +1451,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1306,7 +1469,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -1346,15 +1509,22 @@ version = "0.1.0" dependencies = [ "anyhow", "arboard", + "assert_cmd", + "chrono", "clap", + "data-encoding", "dirs", "ed25519-dalek", "hex", + "image", + "predicates", "rand", "relicario-core", "rpassword", "serde", "serde_json", + "tempfile", + "url", "zeroize", ] @@ -1367,12 +1537,14 @@ dependencies = [ "chacha20poly1305", "chrono", "ed25519-dalek", - "getrandom", + "getrandom 0.2.17", "hex", + "hmac", "image", "rand", "serde", "serde_json", + "sha1", "sha2", "thiserror 2.0.18", "unicode-normalization", @@ -1385,26 +1557,36 @@ dependencies = [ name = "relicario-wasm" version = "0.1.0" dependencies = [ - "data-encoding", - "getrandom", - "hmac", + "getrandom 0.2.17", "image", - "js-sys", "relicario-core", + "serde", + "serde-wasm-bindgen", "serde_json", - "sha1", "wasm-bindgen", "wasm-bindgen-test", + "zeroize", ] [[package]] name = "rpassword" -version = "5.0.1" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" dependencies = [ "libc", - "winapi", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", ] [[package]] @@ -1466,6 +1648,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1604,6 +1797,25 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -1723,6 +1935,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -1764,6 +1982,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1780,6 +2007,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -1874,6 +2119,40 @@ version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472" +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -1890,22 +2169,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -1915,12 +2178,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -1989,6 +2246,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -2022,6 +2288,22 @@ dependencies = [ "windows_x86_64_msvc 0.48.5", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -2032,7 +2314,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", @@ -2045,6 +2327,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" @@ -2057,6 +2345,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" @@ -2069,12 +2363,24 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" @@ -2087,6 +2393,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" @@ -2099,6 +2411,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" @@ -2111,6 +2429,12 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" @@ -2123,12 +2447,112 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 76574c5..1cb4766 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -29,3 +29,5 @@ data-encoding = "2" assert_cmd = "2" predicates = "3" tempfile = "3" +image = { version = "0.25", default-features = false, features = ["jpeg"] } +serde_json = "1" diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 2669307..fd30c0d 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -272,8 +272,18 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { } // Passphrase with strength gate (audit H3). - let passphrase = Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?); - let confirm = Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?); + // RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the + // TTY prompt so integration tests can run without a real TTY. + let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?) + }; + let confirm = if std::env::var_os("RELICARIO_TEST_PASSPHRASE").is_some() { + passphrase.clone() + } else { + Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) + }; if passphrase.as_str() != confirm.as_str() { anyhow::bail!("passphrases do not match"); } diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index e4bb823..62c2c0b 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -39,10 +39,14 @@ impl UnlockedVault { .with_context(|| format!("failed to read reference image {}", image_path.display()))?; let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?); - let passphrase = Zeroizing::new( - rpassword::prompt_password("Passphrase: ") - .context("failed to read passphrase")? - ); + let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") { + Zeroizing::new(p) + } else { + Zeroizing::new( + rpassword::prompt_password("Passphrase: ") + .context("failed to read passphrase")? + ) + }; let master_key = derive_master_key( passphrase.as_bytes(), @@ -104,10 +108,16 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> { } fn read_params(root: &Path) -> Result { + // params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... } + // We extract only the "kdf" sub-object and deserialize it as KdfParams. + #[derive(serde::Deserialize)] + struct ParamsFile { + kdf: KdfParams, + } let s = fs::read_to_string(root.join(".relicario").join("params.json")) .context("failed to read .relicario/params.json")?; - let params: KdfParams = serde_json::from_str(&s).context("failed to parse params.json")?; - Ok(params) + let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?; + Ok(pf.kdf) } /// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt. diff --git a/crates/relicario-cli/tests/basic_flows.rs b/crates/relicario-cli/tests/basic_flows.rs new file mode 100644 index 0000000..dc493ec --- /dev/null +++ b/crates/relicario-cli/tests/basic_flows.rs @@ -0,0 +1,136 @@ +mod common; + +use assert_cmd::cargo::CommandCargoExt as _; +use common::TestVault; + +#[test] +fn init_creates_expected_layout() { + let v = TestVault::init(); + assert!(v.path().join(".relicario/salt").exists()); + assert!(v.path().join(".relicario/params.json").exists()); + assert!(v.path().join(".relicario/devices.json").exists()); + assert!(v.path().join("manifest.enc").exists()); + assert!(v.path().join("settings.enc").exists()); + assert!(v.path().join("reference.jpg").exists()); + assert!(v.path().join(".gitignore").exists()); + assert!(v.path().join(".git").is_dir()); +} + +#[test] +fn init_params_json_is_format_v2() { + let v = TestVault::init(); + let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed["format_version"], 2); + assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13"); + assert_eq!(parsed["aead"], "xchacha20poly1305"); +} + +#[test] +fn add_login_then_list_shows_it() { + let v = TestVault::init(); + let out = v.run(&[ + "add", + "login", + "--title", + "GitHub", + "--username", + "alice", + "--url", + "https://github.com", + "--password", + "hunter2", + ]); + assert!(out.status.success(), "add failed: {:?}", out); + let out = v.run(&["list"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}"); +} + +#[test] +fn get_masks_by_default_shows_with_flag() { + let v = TestVault::init(); + v.run(&[ + "add", + "login", + "--title", + "gmail", + "--username", + "u", + "--password", + "super-secret", + ]); + + let masked = v.run(&["get", "gmail"]); + let stdout = String::from_utf8(masked.stdout).unwrap(); + assert!(stdout.contains("********"), "expected masked: {stdout}"); + assert!( + !stdout.contains("super-secret"), + "leaked plaintext: {stdout}" + ); + + let shown = v.run(&["get", "gmail", "--show"]); + let stdout = String::from_utf8(shown.stdout).unwrap(); + assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}"); +} + +#[test] +fn rm_restore_purge_cycle() { + let v = TestVault::init(); + v.run(&[ + "add", + "login", + "--title", + "target", + "--username", + "u", + "--password", + "p", + ]); + + let rm = v.run(&["rm", "target"]); + assert!(rm.status.success()); + + let out = v.run(&["list"]); + assert!(!String::from_utf8(out.stdout).unwrap().contains("target")); + + let out = v.run(&["list", "--trashed"]); + assert!(String::from_utf8(out.stdout).unwrap().contains("target")); + + let restore = v.run(&["restore", "target"]); + assert!(restore.status.success()); + let out = v.run(&["list"]); + assert!(String::from_utf8(out.stdout).unwrap().contains("target")); + + let purge = v.run(&["purge", "target"]); + assert!(purge.status.success()); + let out = v.run(&["list"]); + assert!(!String::from_utf8(out.stdout).unwrap().contains("target")); +} + +#[test] +fn generate_random_and_bip39() { + let dir = tempfile::TempDir::new().unwrap(); + + let out = std::process::Command::cargo_bin("relicario") + .unwrap() + .current_dir(dir.path()) + .args(["generate", "--length", "32"]) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!( + String::from_utf8(out.stdout).unwrap().trim().len(), + 32 + ); + + let out = std::process::Command::cargo_bin("relicario") + .unwrap() + .current_dir(dir.path()) + .args(["generate", "--bip39", "--words", "5"]) + .output() + .unwrap(); + assert!(out.status.success()); + let phrase = String::from_utf8(out.stdout).unwrap(); + assert_eq!(phrase.trim().split(' ').count(), 5); +} diff --git a/crates/relicario-cli/tests/common/mod.rs b/crates/relicario-cli/tests/common/mod.rs new file mode 100644 index 0000000..b77ce7e --- /dev/null +++ b/crates/relicario-cli/tests/common/mod.rs @@ -0,0 +1,117 @@ +//! Shared helpers for CLI integration tests. +//! +//! `TestVault::init()` spins up a fresh vault in a `TempDir` using +//! `RELICARIO_TEST_PASSPHRASE` as the escape hatch (bypasses TTY prompts). +//! Every `run()` / `run_with_input()` call sets both `RELICARIO_IMAGE` and +//! `RELICARIO_TEST_PASSPHRASE`, so vault-mutating commands unlock without +//! interactive input. +//! +//! Note for Task 23 implementers: commands that prompt for a *new item +//! password* (i.e. `edit` when changing a Login password) also use +//! `rpassword`. Plumb `RELICARIO_TEST_ITEM_PASSWORD` through `cmd_edit` in +//! main.rs, or use an item type / edit path that avoids the rpassword call. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use assert_cmd::cargo::CommandCargoExt; +use tempfile::TempDir; + +pub struct TestVault { + pub dir: TempDir, + pub reference_image: PathBuf, + pub passphrase: String, +} + +impl TestVault { + pub fn init() -> Self { + let dir = TempDir::new().expect("tempdir"); + let carrier = make_test_jpeg(400, 300); + let carrier_path = dir.path().join("carrier.jpg"); + std::fs::write(&carrier_path, &carrier).unwrap(); + + let passphrase = "correct horse battery staple 2026".to_string(); + let ref_path = dir.path().join("reference.jpg"); + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(dir.path()) + .env("RELICARIO_TEST_PASSPHRASE", &passphrase) + .args([ + "init", + "--image", + carrier_path.to_str().unwrap(), + "--output", + ref_path.to_str().unwrap(), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "init failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + Self { + dir, + reference_image: ref_path, + passphrase, + } + } + + pub fn path(&self) -> &Path { + self.dir.path() + } + + pub fn run(&self, args: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(self.dir.path()) + .env("RELICARIO_IMAGE", &self.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &self.passphrase) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.output().unwrap() + } + + pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(self.dir.path()) + .env("RELICARIO_IMAGE", &self.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &self.passphrase) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + for line in extra { + writeln!(stdin, "{line}").unwrap(); + } + } + child.wait_with_output().unwrap() + } +} + +pub fn make_test_jpeg(w: u32, h: u32) -> Vec { + use image::codecs::jpeg::JpegEncoder; + use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb}; + + let img = ImageBuffer::from_fn(w, h, |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 out = Vec::new(); + JpegEncoder::new_with_quality(&mut out, 92) + .write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8) + .unwrap(); + out +} From 20350d509b37444d54967dfe0d4e3dc293604cc7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 18:37:56 -0400 Subject: [PATCH 67/69] test(cli): integration tests for edit/history, attachments, settings Adds RELICARIO_TEST_ITEM_SECRET env hatch for rpassword calls in cmd_add / cmd_edit so piped-stdin tests can exercise the password prompt paths without a TTY. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/src/main.rs | 24 +++++--- crates/relicario-cli/tests/attachments.rs | 45 ++++++++++++++ .../relicario-cli/tests/edit_and_history.rs | 59 +++++++++++++++++++ crates/relicario-cli/tests/settings.rs | 23 ++++++++ 4 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 crates/relicario-cli/tests/attachments.rs create mode 100644 crates/relicario-cli/tests/edit_and_history.rs create mode 100644 crates/relicario-cli/tests/settings.rs diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index fd30c0d..5cd7362 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -256,6 +256,16 @@ fn main() -> Result<()> { } } +/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` +/// for integration-test use (rpassword reads /dev/tty by default, which is +/// unavailable in assert_cmd-spawned children). +fn prompt_secret(label: &str) -> Result { + if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") { + return Ok(s); + } + rpassword::prompt_password(label).map_err(Into::into) +} + fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { use std::fs; use rand::{rngs::OsRng, RngCore}; @@ -381,7 +391,7 @@ fn cmd_add(kind: AddKind) -> Result<()> { let password = if let Some(p) = password { Some(Zeroizing::new(p)) } else if password_prompt { - Some(Zeroizing::new(rpassword::prompt_password("Password: ")?)) + Some(Zeroizing::new(prompt_secret("Password: ")?)) } else { None }; @@ -447,10 +457,10 @@ fn cmd_add(kind: AddKind) -> Result<()> { use zeroize::Zeroizing; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let number = Zeroizing::new(rpassword::prompt_password("Card number: ")?); - let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?); + let number = Zeroizing::new(prompt_secret("Card number: ")?); + let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?); let cvv = if cvv.is_empty() { None } else { Some(cvv) }; - let pin = Zeroizing::new(rpassword::prompt_password("PIN (blank to skip): ")?); + let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?); let pin = if pin.is_empty() { None } else { Some(pin) }; let parsed_expiry = match expiry { @@ -552,7 +562,7 @@ fn cmd_add(kind: AddKind) -> Result<()> { let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; let secret_b32 = match secret { Some(s) => s, - None => rpassword::prompt_password("TOTP secret (base32): ")?, + None => prompt_secret("TOTP secret (base32): ")?, }; let secret_bytes = base32_decode_lenient(&secret_b32)?; let algo = match algorithm.to_ascii_lowercase().as_str() { @@ -862,7 +872,7 @@ fn cmd_edit(query: String) -> Result<()> { } if prompt_yesno("Change password?")? { let old = l.password.clone(); - let new_pw = Zeroizing::new(rpassword::prompt_password("New password: ")?); + let new_pw = Zeroizing::new(prompt_secret("New password: ")?); l.password = Some(new_pw); if let Some(old_pw) = old { push_history(&mut item.field_history, "login_password", @@ -890,7 +900,7 @@ fn cmd_edit(query: String) -> Result<()> { if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); } if prompt_yesno("Change card number?")? { let old = c.number.clone(); - c.number = Some(Zeroizing::new(rpassword::prompt_password("New number: ")?)); + c.number = Some(Zeroizing::new(prompt_secret("New number: ")?)); if let Some(o) = old { push_history(&mut item.field_history, "card_number", Zeroizing::new(o.as_str().to_string())); diff --git a/crates/relicario-cli/tests/attachments.rs b/crates/relicario-cli/tests/attachments.rs new file mode 100644 index 0000000..d590d38 --- /dev/null +++ b/crates/relicario-cli/tests/attachments.rs @@ -0,0 +1,45 @@ +mod common; + +use common::TestVault; + +#[test] +fn attach_list_extract_round_trip() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + let payload_path = v.path().join("payload.txt"); + std::fs::write(&payload_path, b"attached-bytes").unwrap(); + + let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]); + assert!(attach.status.success(), "attach failed: {:?}", attach); + + let list = v.run(&["attachments", "thing"]); + let stdout = String::from_utf8(list.stdout).unwrap(); + assert!(stdout.contains("payload.txt"), "missing payload: {stdout}"); + + let aid = stdout.lines() + .find(|l| l.contains("payload.txt")) + .and_then(|l| l.split_whitespace().next()) + .expect("aid token"); + + let out_path = v.path().join("extracted.txt"); + let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]); + assert!(ex.status.success(), "extract failed: {:?}", ex); + assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes"); +} + +#[test] +fn attach_rejects_over_cap() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]); + + let big = v.path().join("big.bin"); + std::fs::write(&big, vec![0u8; 100]).unwrap(); + let out = v.run(&["attach", "thing", big.to_str().unwrap()]); + assert!(!out.status.success(), "expected failure; got {:?}", out); + assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment")); +} diff --git a/crates/relicario-cli/tests/edit_and_history.rs b/crates/relicario-cli/tests/edit_and_history.rs new file mode 100644 index 0000000..077c31c --- /dev/null +++ b/crates/relicario-cli/tests/edit_and_history.rs @@ -0,0 +1,59 @@ +mod common; + +use common::TestVault; + +#[test] +fn edit_password_captures_history() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "bank", + "--username", "u", "--password", "first-pw"]); + + // edit: accept defaults on title/group/tags/username/url, then change pw. + let out = run_edit_with_pw_change(&v, "bank", "second-pw"); + assert!(out.status.success(), "edit failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr)); + + // Verify the edit commit exists in git log. + let log = std::process::Command::new("git") + .current_dir(v.path()).args(["log", "--oneline"]) + .output().unwrap(); + let log_str = String::from_utf8(log.stdout).unwrap(); + assert!(log_str.contains("edit: bank"), "missing edit commit: {log_str}"); + + // And the item file has been re-written (there's a single items/.enc). + let items_dir = v.path().join("items"); + let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap() + .map(|e| e.unwrap().path()).collect(); + assert_eq!(entries.len(), 1); +} + +/// Drives the interactive `edit` flow end-to-end: +/// 1. passphrase via env var. +/// 2. blank lines for title, group, tags, username, url. +/// 3. "y" for "Change password?" +/// 4. new password via RELICARIO_TEST_ITEM_SECRET env var. +fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::process::Output { + use assert_cmd::cargo::CommandCargoExt; + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(v.path()) + .env("RELICARIO_IMAGE", &v.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .env("RELICARIO_TEST_ITEM_SECRET", new_pw) + .args(["edit", query]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + // title, group, tags, username, url (keep defaults), then yes-to-change-pw. + for line in ["", "", "", "", "", "y"] { + writeln!(stdin, "{line}").unwrap(); + } + } + child.wait_with_output().unwrap() +} diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs new file mode 100644 index 0000000..2a5b490 --- /dev/null +++ b/crates/relicario-cli/tests/settings.rs @@ -0,0 +1,23 @@ +mod common; + +use common::TestVault; + +#[test] +fn settings_roundtrip_trash_retention() { + let v = TestVault::init(); + let out = v.run(&["settings", "show"]); + assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention")); + + let out = v.run(&["settings", "trash-retention", "--days", "60"]); + assert!(out.status.success(), "set failed: {:?}", out); + let out = v.run(&["settings", "show"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("60"), "expected 60: {stdout}"); +} + +#[test] +fn settings_rejects_conflicting_retention_flags() { + let v = TestVault::init(); + let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]); + assert!(!out.status.success()); +} From c3edf9d4137e83bb5c2f097acb8b27024fb5be4e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 18:39:13 -0400 Subject: [PATCH 68/69] test(cli): vault_dir detection (L8) + v1 vault rejection Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/tests/vault_detection.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 crates/relicario-cli/tests/vault_detection.rs diff --git a/crates/relicario-cli/tests/vault_detection.rs b/crates/relicario-cli/tests/vault_detection.rs new file mode 100644 index 0000000..c25071e --- /dev/null +++ b/crates/relicario-cli/tests/vault_detection.rs @@ -0,0 +1,59 @@ +mod common; + +use assert_cmd::cargo::CommandCargoExt; +use std::process::Command; +use tempfile::TempDir; + +#[test] +fn list_refuses_without_vault_marker() { + let dir = TempDir::new().unwrap(); + // No .relicario/ in dir — list should bail with a friendly error. + let mut cmd = Command::cargo_bin("relicario").unwrap(); + let out = cmd.current_dir(dir.path()) + .env("RELICARIO_TEST_PASSPHRASE", "foo") + .arg("list") + .output() + .unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}"); +} + +#[test] +fn get_finds_vault_in_parent_dir() { + let v = common::TestVault::init(); + v.run(&["add", "login", "--title", "parent-test", + "--username", "u", "--password", "p"]); + + // Create a nested subdir and run `list` from inside it. + let nested = v.path().join("a/b/c"); + std::fs::create_dir_all(&nested).unwrap(); + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(&nested) + .env("RELICARIO_IMAGE", &v.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .arg("list"); + let out = cmd.output().unwrap(); + assert!(out.status.success(), "list from nested dir failed: {:?}", out); + assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test")); +} + +#[test] +fn v1_vault_is_rejected_with_clear_error() { + // Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json. + // Since vault_dir detection uses .relicario/, the pre-rename dir name is + // naturally rejected without any compat shim. Confirm that. + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".idfoto")).unwrap(); + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + let out = cmd.current_dir(dir.path()) + .env("RELICARIO_TEST_PASSPHRASE", "foo") + .arg("list") + .output() + .unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}"); +} From 65e0d3cb80354e04ee31935b301c0d84f01e13b1 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 18:47:08 -0400 Subject: [PATCH 69/69] docs: update CLAUDE.md for the typed-item module layout Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 51 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 171900d..eeb855a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,30 +7,43 @@ relicario is a git-backed, self-hostable password manager with a Rust core. Two- ## Build and test ```bash -cargo build # build everything -cargo test # run all tests (unit + integration) -cargo test -p relicario-core # core library tests only -cargo run -- --help # CLI help -cargo run -- generate -l 32 # quick smoke test +cargo build # build everything +cargo test # run all tests (unit + integration) +cargo test -p relicario-core # core library tests only +cargo test -p relicario-cli --test basic_flows # CLI integration tests +cargo build -p relicario-wasm --target wasm32-unknown-unknown # WASM target +cargo run -p relicario-cli -- --help # CLI help +cargo run -p relicario-cli -- generate --length 32 # quick smoke test ``` ## Project structure ``` crates/ -├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network) +├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network) │ ├── src/ -│ │ ├── lib.rs # Re-exports public API -│ │ ├── error.rs # RelicarioError enum (thiserror) -│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 encrypt/decrypt -│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs (serde) -│ │ ├── vault.rs # encrypt_entry, decrypt_entry, encrypt_manifest, decrypt_manifest -│ │ └── imgsecret.rs # DCT-based 256-bit secret embedding in JPEGs -│ └── tests/ -│ └── integration.rs # Full-workflow and two-factor independence tests -└── relicario-cli/ # CLI binary - └── src/ - └── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device +│ │ ├── lib.rs # Re-exports public API +│ │ ├── error.rs # RelicarioError enum (thiserror) +│ │ ├── crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305 +│ │ ├── ids.rs # ItemId, FieldId, content-addressed AttachmentId +│ │ ├── time.rs # now_unix, MonthYear +│ │ ├── item_types/ # per-type cores + ItemType/ItemCore enums +│ │ ├── item.rs # Item envelope, Field, FieldKind, FieldValue, Section +│ │ ├── attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers +│ │ ├── manifest.rs # Browse-without-decrypt index (schema_version 2) +│ │ ├── settings.rs # VaultSettings: retention, generator defaults, caps +│ │ ├── generators.rs # CSPRNG password + BIP39 + zxcvbn gate +│ │ ├── vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings +│ │ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap) +│ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs +├── relicario-cli/ # `relicario` binary +│ ├── src/main.rs # clap surface + command handlers +│ ├── src/helpers.rs # vault_dir, git_command, iso8601 +│ ├── src/session.rs # UnlockedVault (master key in Zeroizing) +│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection +└── relicario-wasm/ # WASM bindings for the extension + ├── src/lib.rs # #[wasm_bindgen] surface + └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]> ``` ## Key design decisions @@ -49,14 +62,14 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG) → Argon2id(salt=vault_salt, m=64MiB, t=3, p=4) → master_key (32 bytes) → XChaCha20-Poly1305(nonce=random 24 bytes) - → encrypted entry/manifest + → encrypted Item/Manifest/VaultSettings ``` ## Conventions - Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever. - Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures. -- Entry IDs are random 8-char hex strings. +- Item IDs are random 8-char hex strings. - Git history is preserved as an audit log — no squashing. - The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.