From b08b388d5d9381011cee8df437c7d465bad71970 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:02:13 -0400 Subject: [PATCH] feat(core+cli): SecondFactor hint on ParamsFile (default image, back-compat) Add SecondFactor enum (Image | Keyfile, serde lowercase) to relicario-core::crypto, re-export from lib.rs alongside KdfParams. Add #[serde(default)] second_factor field to ParamsFile in relicario-cli::session so existing params.json files (no field) still parse as Image. for_new_vault() initialises the field to Image. No unlock logic changed; that is deferred to Task 4. TDD: tests written RED first, then GREEN. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/session.rs | 34 ++++++++++++++++++++++++++++- crates/relicario-core/src/crypto.rs | 34 +++++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 2 +- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 0e4f0ed..8b2fbbb 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -12,7 +12,7 @@ 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, + imgsecret, Item, ItemId, KdfParams, Manifest, SecondFactor, VaultSettings, }; use crate::helpers::vault_dir; @@ -119,6 +119,11 @@ pub(crate) struct ParamsFile { pub kdf: ParamsKdf, pub aead: String, pub salt_path: String, + /// Which second-factor container this vault uses to supply its 256-bit secret. + /// Defaults to `Image` (reference JPEG via DCT stego) for back-compat with + /// params.json files written before keyfile support was introduced. + #[serde(default)] + pub second_factor: SecondFactor, } #[derive(serde::Serialize, serde::Deserialize)] @@ -142,6 +147,7 @@ impl ParamsFile { }, aead: "xchacha20poly1305".into(), salt_path: ".relicario/salt".into(), + second_factor: SecondFactor::Image, } } @@ -248,6 +254,32 @@ mod tests { assert_eq!(v["salt_path"], ".relicario/salt"); } + #[test] + fn params_file_second_factor_backcompat() { + // An old params.json without second_factor must deserialize with Image (back-compat). + let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse old fixture"); + assert_eq!(pf.second_factor, relicario_core::SecondFactor::Image); + } + + #[test] + fn params_file_second_factor_keyfile() { + // A params.json WITH "second_factor":"keyfile" must deserialize as Keyfile. + let with_keyfile = r#"{ + "format_version": 2, + "kdf": { + "algorithm": "argon2id-v0x13", + "argon2_m": 65536, + "argon2_t": 3, + "argon2_p": 4 + }, + "aead": "xchacha20poly1305", + "salt_path": ".relicario/salt", + "second_factor": "keyfile" +}"#; + let pf: ParamsFile = serde_json::from_str(with_keyfile).expect("parse keyfile fixture"); + assert_eq!(pf.second_factor, relicario_core::SecondFactor::Keyfile); + } + #[test] fn after_manifest_change_writes_manifest_and_groups_cache() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/crates/relicario-core/src/crypto.rs b/crates/relicario-core/src/crypto.rs index 78426b4..bbf7485 100644 --- a/crates/relicario-core/src/crypto.rs +++ b/crates/relicario-core/src/crypto.rs @@ -55,6 +55,29 @@ use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; +/// Which second-factor container a vault uses to supply its 256-bit secret. +/// +/// This is a non-secret hint stored in params.json so that the CLI (and future +/// clients) know which unlock branch to follow. It does **not** influence the +/// KDF itself; only the container that delivers `image_secret` changes. +/// +/// Serialized as lowercase via `serde(rename_all = "lowercase")`: +/// - `"image"` — default; secret embedded in a reference JPEG via DCT stego +/// - `"keyfile"` — secret stored in a plain key file (Task 4+) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SecondFactor { + /// The secret is embedded in a reference JPEG via DCT steganography. + /// This is the original and default mode for every vault created before + /// keyfile support was introduced. + #[default] + Image, + /// The secret is stored in a plain key file on disk. + /// Vaults with this hint require a key-file path at unlock time instead of + /// a reference image. + Keyfile, +} + /// Current binary format version. Increment this if the ciphertext layout changes. pub const VERSION_BYTE: u8 = 0x02; @@ -417,6 +440,17 @@ mod tests { assert_eq!(VERSION_BYTE, 0x02); } + #[test] + fn second_factor_serde_behavior() { + assert_eq!(serde_json::to_string(&SecondFactor::Image).unwrap(), "\"image\""); + assert_eq!(serde_json::to_string(&SecondFactor::Keyfile).unwrap(), "\"keyfile\""); + assert_eq!( + serde_json::from_str::("\"keyfile\"").unwrap(), + SecondFactor::Keyfile + ); + assert_eq!(SecondFactor::default(), SecondFactor::Image); + } + #[test] fn decrypt_rejects_v1_blob_with_typed_error() { // Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes]. diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 5c1e607..353ebbc 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -43,7 +43,7 @@ pub mod error; pub use error::{RelicarioError, Result}; pub mod crypto; -pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; +pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, SecondFactor, VERSION_BYTE}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId};