From b08b388d5d9381011cee8df437c7d465bad71970 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:02:13 -0400 Subject: [PATCH 01/12] 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}; From 3b7099c6f435fd2a664c819a4daf650f2eaacae9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:09:40 -0400 Subject: [PATCH 02/12] feat(core): key-file armor (relicario-keyfile-v1) encode/decode Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-core/src/keyfile.rs | 73 ++++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 2 + 2 files changed, 75 insertions(+) create mode 100644 crates/relicario-core/src/keyfile.rs diff --git a/crates/relicario-core/src/keyfile.rs b/crates/relicario-core/src/keyfile.rs new file mode 100644 index 0000000..c8a0509 --- /dev/null +++ b/crates/relicario-core/src/keyfile.rs @@ -0,0 +1,73 @@ +//! Key-file armor for the pluggable second factor. The file holds the raw +//! 32-byte secret (base64) behind a version header -- it is the "something +//! you have", not an encrypted artifact (the passphrase is the other factor). +//! +//! The format is intentionally minimal and human-inspectable: +//! +//! ```text +//! relicario-keyfile-v1 +//! +//! ``` +//! +//! Two lines, both terminated with `\n`. There is no encryption here: the file +//! is as sensitive as the raw secret itself. Store it accordingly (encrypted +//! drive, hardware token, offline backup). The passphrase remains the second +//! factor; together they feed Argon2id to derive the master key. + +use base64::{engine::general_purpose::STANDARD, Engine}; +use zeroize::Zeroizing; + +use crate::error::{RelicarioError, Result}; + +const HEADER: &str = "relicario-keyfile-v1"; + +/// Encode a 32-byte secret into the `relicario-keyfile-v1` armor format. +/// +/// Output is: `"relicario-keyfile-v1\n\n"` as UTF-8 bytes. +/// The returned bytes are suitable for writing directly to a `.relkey` file. +pub fn keyfile_encode(secret: &[u8; 32]) -> Vec { + format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes() +} + +/// Decode a `relicario-keyfile-v1` armored file back to the raw 32-byte secret. +/// +/// Returns [`RelicarioError::Format`] if the header is missing or wrong, if the +/// body is not valid base64, or if the decoded length is not exactly 32 bytes. +pub fn keyfile_decode(bytes: &[u8]) -> Result> { + let text = std::str::from_utf8(bytes) + .map_err(|_| RelicarioError::Format("key file is not UTF-8".into()))?; + let mut lines = text.lines(); + if lines.next() != Some(HEADER) { + return Err(RelicarioError::Format("bad key-file header".into())); + } + let b64 = lines.next().unwrap_or("").trim(); + let decoded = STANDARD + .decode(b64) + .map_err(|_| RelicarioError::Format("key-file body not base64".into()))?; + let arr: [u8; 32] = decoded + .as_slice() + .try_into() + .map_err(|_| RelicarioError::Format("key-file secret must be 32 bytes".into()))?; + Ok(Zeroizing::new(arr)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn round_trip() { + let secret = [9u8; 32]; + let armored = keyfile_encode(&secret); + assert!(std::str::from_utf8(&armored).unwrap().starts_with("relicario-keyfile-v1\n")); + let back = keyfile_decode(&armored).unwrap(); + assert_eq!(*back, secret); + } + #[test] + fn rejects_bad_header() { + assert!(keyfile_decode(b"not-a-keyfile\nAAAA\n").is_err()); + } + #[test] + fn rejects_wrong_length() { + assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes + } +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 353ebbc..f362ec2 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -84,6 +84,8 @@ pub use vault::{ pub mod imgsecret; +pub mod keyfile; + pub mod backup; pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment}; From eabc93bd84bd69b27e0172caf9dd0dd231942f9f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:15:11 -0400 Subject: [PATCH 03/12] fix(core): zeroize keyfile decode intermediate + clearer errors + error-arm tests --- crates/relicario-core/src/keyfile.rs | 32 ++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/relicario-core/src/keyfile.rs b/crates/relicario-core/src/keyfile.rs index c8a0509..c84cf73 100644 --- a/crates/relicario-core/src/keyfile.rs +++ b/crates/relicario-core/src/keyfile.rs @@ -40,15 +40,21 @@ pub fn keyfile_decode(bytes: &[u8]) -> Result> { if lines.next() != Some(HEADER) { return Err(RelicarioError::Format("bad key-file header".into())); } - let b64 = lines.next().unwrap_or("").trim(); - let decoded = STANDARD - .decode(b64) - .map_err(|_| RelicarioError::Format("key-file body not base64".into()))?; - let arr: [u8; 32] = decoded - .as_slice() - .try_into() - .map_err(|_| RelicarioError::Format("key-file secret must be 32 bytes".into()))?; - Ok(Zeroizing::new(arr)) + let b64 = match lines.next() { + Some(line) => line.trim(), + None => return Err(RelicarioError::Format("key-file missing body line".into())), + }; + let decoded: Zeroizing> = Zeroizing::new( + STANDARD + .decode(b64) + .map_err(|_| RelicarioError::Format("key-file body not base64".into()))?, + ); + if decoded.len() != 32 { + return Err(RelicarioError::Format("key-file secret must be 32 bytes".into())); + } + let mut arr = Zeroizing::new([0u8; 32]); + arr.copy_from_slice(&decoded); + Ok(arr) } #[cfg(test)] @@ -70,4 +76,12 @@ mod tests { fn rejects_wrong_length() { assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes } + #[test] + fn rejects_non_utf8() { + assert!(keyfile_decode(b"\xff\xfe\nAAAA\n").is_err()); + } + #[test] + fn rejects_missing_body() { + assert!(keyfile_decode(b"relicario-keyfile-v1\n").is_err()); + } } From 6cb12990eccf747c666e7c1bd0b64244927b4826 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:24:10 -0400 Subject: [PATCH 04/12] feat(wasm): unlock_with_secret + keyfile encode/decode bindings + equivalence proof Adds three WASM bindings for the pluggable second-factor work: - unlock_with_secret: mirrors unlock() but accepts a raw 32-byte secret instead of a carrier image, skipping imgsecret::extract - keyfile_encode: armors a 32-byte secret into relicario-keyfile-v1 format - keyfile_decode: decodes keyfile armor back to the raw 32-byte secret Adds native equivalence test proving that unlock_with_secret and unlock derive an identical Argon2id master key when given the same secret, passphrase, salt, and KDF params. Comparison is direct (master-key bytes), not cross-decrypt, so the test is both native-safe and unambiguous. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-wasm/src/lib.rs | 101 ++++++++++++++++++++++++++++++- extension/src/wasm.d.ts | 5 ++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f6c6b80..21eb255 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -70,7 +70,46 @@ pub fn lock(handle: &SessionHandle) -> bool { session::remove(handle.0) } -// Subsequent wasm_bindgen fns added in Tasks 19-21. +// ── Pluggable second factor: key-file bindings (Task 3) ────────────────────── + +/// Unlock using a raw 32-byte secret directly, bypassing steganographic +/// extraction. Mirrors `unlock` exactly, except the caller supplies the +/// secret bytes instead of a carrier image. +#[wasm_bindgen] +pub fn unlock_with_secret( + passphrase: &str, + secret: &[u8], + salt: &[u8], + params_json: &str, +) -> Result { + let params: KdfParams = serde_json::from_str(params_json) + .map_err(|e| JsError::new(&format!("params: {e}")))?; + let secret_arr: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + 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(), secret_arr, salt_arr, ¶ms) + .map_err(|e| JsError::new(&e.to_string()))?; + let handle = session::insert(master_key, Zeroizing::new(*secret_arr)); + Ok(SessionHandle(handle)) +} + +/// Encode a 32-byte secret into the `relicario-keyfile-v1` armor format. +/// Returns the UTF-8 bytes suitable for writing to a `.relkey` file. +#[wasm_bindgen] +pub fn keyfile_encode(secret: &[u8]) -> Result, JsError> { + let arr: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + Ok(relicario_core::keyfile::keyfile_encode(arr)) +} + +/// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret. +#[wasm_bindgen] +pub fn keyfile_decode(bytes: &[u8]) -> Result, JsError> { + let s = relicario_core::keyfile::keyfile_decode(bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(s.to_vec()) +} use serde_wasm_bindgen::Serializer; use relicario_core::{ @@ -573,6 +612,66 @@ mod session_tests { use super::*; use zeroize::Zeroizing; + /// Synthesize a carrier JPEG for embedding tests (mirrors core's private helper). + 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(); + JpegEncoder::new_with_quality(&mut buf, 92) + .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .unwrap(); + buf + } + + /// SECURITY PROOF: both unlock paths derive the identical master key. + /// + /// The key-file path (`unlock_with_secret`) must derive the same Argon2id + /// master key as the stego-image path (`unlock`) when given the same secret, + /// passphrase, salt, and KDF params. This is the core security argument: + /// the key file is simply an alternative transport for the same 32-byte + /// secret that the stego-image carries. + #[test] + fn unlock_with_secret_matches_unlock_from_jpeg() { + session::clear(); + let secret = [3u8; 32]; + let salt = [1u8; 32]; + let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; + + // Image transport: embed the secret into a carrier JPEG, then unlock from it. + let carrier = make_test_jpeg(256, 256); + let stego = imgsecret::embed(&carrier, &secret).expect("embed secret into carrier"); + let h_img = unlock("correct horse", &stego, &salt, params).expect("unlock from jpeg"); + + // Key-file transport: unlock from the raw secret directly. + let h_key = unlock_with_secret("correct horse", &secret, &salt, params) + .expect("unlock_with_secret"); + + // PROOF: both sessions hold a byte-identical master key — the key-file + // path derives the SAME master key as the stego-image path for the + // same secret. We compare stored master keys directly (avoids js-sys, + // which is unavailable natively; stronger than a cross-decrypt anyway). + let extract = |h: u32| { + session::with(h, |k| { + let mut a = [0u8; 32]; + a.copy_from_slice(&k[..]); + a + }) + .expect("session present") + }; + assert_eq!( + extract(h_img.value()), + extract(h_key.value()), + "key-file path must derive the identical master key as the stego-image path" + ); + } + #[test] fn insert_then_remove_clears_entry() { session::clear(); diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 2553999..d8512b1 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -87,6 +87,11 @@ declare module 'relicario-wasm' { export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string; export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array; + // Pluggable second factor: key-file bindings (Task 3) + export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle; + export function keyfile_encode(secret: Uint8Array): Uint8Array; + export function keyfile_decode(bytes: Uint8Array): Uint8Array; + export default function init(module_or_path?: unknown): Promise; export function initSync(args: { module: WebAssembly.Module }): void; } From e8f66351884cdba691871e629e409ce4f330c732 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:37:59 -0400 Subject: [PATCH 05/12] feat(cli): unlock resolves second factor from params hint (image|keyfile) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/session.rs | 56 +++++++++++++++++---- crates/relicario-cli/tests/keyfile_flows.rs | 40 +++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 crates/relicario-cli/tests/keyfile_flows.rs diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 8b2fbbb..df25f12 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -28,16 +28,28 @@ 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. + /// Full interactive unlock flow: locate vault, prompt passphrase, resolve + /// the second factor (reference image or key file), 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 = Zeroizing::new(imgsecret::extract(&image_bytes)?); + let pf = read_params(&root)?; + let params = pf.to_kdf_params(); + + let secret: Zeroizing<[u8; 32]> = match pf.second_factor { + SecondFactor::Image => { + let image_path = get_image_path()?; + let image_bytes = fs::read(&image_path) + .with_context(|| format!("failed to read reference image {}", image_path.display()))?; + Zeroizing::new(imgsecret::extract(&image_bytes)?) + } + SecondFactor::Keyfile => { + let kf_path = get_keyfile_path()?; + let kf_bytes = fs::read(&kf_path) + .with_context(|| format!("failed to read key file {}", kf_path.display()))?; + relicario_core::keyfile::keyfile_decode(&kf_bytes).context("invalid key file")? + } + }; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -50,7 +62,7 @@ impl UnlockedVault { let master_key = derive_master_key( passphrase.as_bytes(), - &image_secret, + &secret, &salt, ¶ms, )?; @@ -160,11 +172,10 @@ impl ParamsFile { } } -fn read_params(root: &Path) -> Result { +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 pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?; - Ok(pf.to_kdf_params()) + serde_json::from_str(&s).context("failed to parse params.json") } /// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt. @@ -186,6 +197,29 @@ pub fn get_image_path() -> Result { Ok(PathBuf::from(trimmed)) } +/// Locate the key file via `RELICARIO_KEYFILE` env var, the +/// `/vault.relkey` convention, or an interactive prompt. +pub fn get_keyfile_path() -> Result { + if let Ok(path) = std::env::var("RELICARIO_KEYFILE") { + return Ok(PathBuf::from(path)); + } + if let Ok(root) = vault_dir() { + let default = root.join("vault.relkey"); + if default.exists() { + return Ok(default); + } + } + eprint!("Key file 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 key file 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<()> { diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs new file mode 100644 index 0000000..4672656 --- /dev/null +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -0,0 +1,40 @@ +use assert_cmd::prelude::*; +use std::process::Command; + +fn relicario(dir: &tempfile::TempDir) -> Command { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(dir.path()); + cmd +} + +/// Full round-trip: init a vault with a key file, then add + get an item using +/// RELICARIO_KEYFILE. Ignored until Task 5 lands `init --key-file`. +#[test] +#[ignore = "un-ignored in Task 5 once init --key-file lands"] +fn init_keyfile_then_unlock_keyfile_round_trips() { + let dir = tempfile::tempdir().unwrap(); + + // init with a key file (Task 5 wires the flag) + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + + // add an item using the generated key file + relicario(&dir) + .args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .success(); + + // get the item — username "u" must appear in stdout + relicario(&dir) + .args(["get", "gh", "--show"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .success() + .stdout(predicates::str::contains("u")); +} From 2daa3502e985b9205af2a2ec52f0f9bd52fd5028 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:32:41 -0400 Subject: [PATCH 06/12] feat(cli): init --key-file generates a .relkey second factor Add `--key-file ` to `relicario init` as a mutually exclusive alternative to `--image`/`--output`. When given, a 32-byte secret is generated with OsRng, armored via `keyfile_encode`, written to the supplied path (gitignored, never committed), and `params.json` records `"second_factor": "keyfile"`. The `--image` arg is now Option; existing image-vault callers are unchanged. `ParamsFile::for_new_vault` now takes the second-factor variant as an explicit argument. Tests: un-ignore init_keyfile_then_unlock_keyfile_round_trips (full round-trip), strengthen its assertion to check for distinctive username "octocat", and add init_keyfile_writes_relkey_and_keyfile_params (armor, params parse, gitignore). Full workspace green (0 failures), clippy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/init.rs | 83 ++++++++++++++++----- crates/relicario-cli/src/main.rs | 9 ++- crates/relicario-cli/src/session.rs | 6 +- crates/relicario-cli/tests/keyfile_flows.rs | 44 +++++++++-- 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/crates/relicario-cli/src/commands/init.rs b/crates/relicario-cli/src/commands/init.rs index f6612b2..ec974f5 100644 --- a/crates/relicario-cli/src/commands/init.rs +++ b/crates/relicario-cli/src/commands/init.rs @@ -4,12 +4,12 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { +pub fn cmd_init(image: Option, output: PathBuf, key_file: Option) -> 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, + validate_passphrase_strength, KdfParams, Manifest, SecondFactor, VaultSettings, }; use zeroize::Zeroizing; @@ -39,17 +39,54 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { anyhow::bail!("{}. Choose a longer or more entropic phrase.", e); } - // Image secret: 32 random bytes, embedded in the carrier. - let image_secret = { + // 32-byte second-factor secret — identical entropy for both container types. + let 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)?; - fs::write(&output, &stego) - .with_context(|| format!("failed to write reference image {}", output.display()))?; + + // Write the container (key file or reference image) and record which + // second-factor type this vault uses. + let (gitignore_name, second_factor): (String, SecondFactor) = match (&key_file, &image) { + (Some(kf), _) => { + // Key-file mode: write the armored secret in the clear. + // SECURITY: this file holds the 256-bit secret in the clear and + // must be gitignored so it is never pushed to the remote — otherwise + // the server would hold both the ciphertext and the key, defeating + // the two-factor model. + let kf_path = root.join(kf); + fs::write(&kf_path, relicario_core::keyfile::keyfile_encode(&secret)) + .with_context(|| format!("failed to write key file {}", kf_path.display()))?; + let name = kf + .file_name() + .ok_or_else(|| anyhow::anyhow!("key-file path has no filename: {}", kf.display()))? + .to_string_lossy() + .into_owned(); + (name, SecondFactor::Keyfile) + } + (None, Some(img)) => { + // Image mode: embed into a carrier JPEG, write the reference image. + let carrier = fs::read(img) + .with_context(|| format!("failed to read carrier image {}", img.display()))?; + let stego = imgsecret::embed(&carrier, &secret)?; + fs::write(&output, &stego) + .with_context(|| format!("failed to write reference image {}", output.display()))?; + let name = output + .file_name() + .ok_or_else(|| { + anyhow::anyhow!("output path has no filename: {}", output.display()) + })? + .to_string_lossy() + .into_owned(); + (name, SecondFactor::Image) + } + (None, None) => { + anyhow::bail!( + "provide either --image (with --output) or --key-file " + ) + } + }; // Vault salt + KDF params. let mut salt = [0u8; 32]; @@ -57,7 +94,7 @@ pub 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(), &secret, &salt, ¶ms)?; fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(root.join("items"))?; @@ -65,21 +102,23 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { fs::write(relicario_dir.join("salt"), salt)?; fs::write( relicario_dir.join("params.json"), - serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?, + serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault( + ¶ms, + second_factor, + ))?, )?; 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 fname = output.file_name() - .ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))? - .to_string_lossy(); - let gitignore = format!("{fname}\n"); + // .gitignore excludes the second-factor container (key file or reference image). + let gitignore = format!("{gitignore_name}\n"); fs::write(root.join(".gitignore"), gitignore)?; // git init + initial commit via hardened wrapper. + // NOTE: only the vault metadata is committed; the key file / reference image + // is intentionally excluded (gitignored) so it never reaches the remote. crate::helpers::git_run(&root, &["init"], "init: git init")?; let _ = crate::helpers::git_command(&root, &[ "add", ".gitignore", ".relicario/params.json", @@ -92,7 +131,15 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { )?; 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."); + match second_factor { + SecondFactor::Keyfile => { + eprintln!("Key file: {gitignore_name}"); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + } + SecondFactor::Image => { + eprintln!("Reference image: {}", output.display()); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + } + } Ok(()) } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 3e38e43..3ff8f89 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -32,12 +32,15 @@ struct Cli { enum Commands { /// Initialize a new vault in the current directory. Init { - /// Carrier JPEG to embed the secret into. + /// Carrier JPEG to embed the secret into (image second factor). #[arg(long)] - image: PathBuf, + image: Option, /// Output path for the reference image (gitignored). #[arg(long, default_value = "reference.jpg")] output: PathBuf, + /// Generate a key-file second factor at this path instead of a reference image (gitignored). + #[arg(long, conflicts_with = "image")] + key_file: Option, }, /// Add a new item. Type-specific flags populate the core; missing fields @@ -628,7 +631,7 @@ pub(crate) enum OrgAddKind { fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Init { image, output } => commands::init::cmd_init(image, output), + Commands::Init { image, output, key_file } => commands::init::cmd_init(image, output, key_file), Commands::Add { kind } => commands::add::cmd_add(kind), Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy), Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed), diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index df25f12..6f5a3ce 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -148,7 +148,7 @@ pub(crate) struct ParamsKdf { } impl ParamsFile { - pub fn for_new_vault(params: &KdfParams) -> Self { + pub fn for_new_vault(params: &KdfParams, second_factor: SecondFactor) -> Self { Self { format_version: 2, kdf: ParamsKdf { @@ -159,7 +159,7 @@ impl ParamsFile { }, aead: "xchacha20poly1305".into(), salt_path: ".relicario/salt".into(), - second_factor: SecondFactor::Image, + second_factor, } } @@ -277,7 +277,7 @@ mod tests { #[test] fn for_new_vault_produces_expected_shape() { let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; - let pf = ParamsFile::for_new_vault(¶ms); + let pf = ParamsFile::for_new_vault(¶ms, relicario_core::SecondFactor::Image); let v = serde_json::to_value(&pf).expect("to_value"); assert_eq!(v["format_version"], 2); assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13"); diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs index 4672656..5369b73 100644 --- a/crates/relicario-cli/tests/keyfile_flows.rs +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -8,13 +8,12 @@ fn relicario(dir: &tempfile::TempDir) -> Command { } /// Full round-trip: init a vault with a key file, then add + get an item using -/// RELICARIO_KEYFILE. Ignored until Task 5 lands `init --key-file`. +/// RELICARIO_KEYFILE. #[test] -#[ignore = "un-ignored in Task 5 once init --key-file lands"] fn init_keyfile_then_unlock_keyfile_round_trips() { let dir = tempfile::tempdir().unwrap(); - // init with a key file (Task 5 wires the flag) + // init with a key file relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") @@ -23,18 +22,51 @@ fn init_keyfile_then_unlock_keyfile_round_trips() { // add an item using the generated key file relicario(&dir) - .args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"]) + .args(["add", "login", "--title", "gh", "--username", "octocat", "--password", "hunter2"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert() .success(); - // get the item — username "u" must appear in stdout + // get the item — distinctive username must appear in stdout relicario(&dir) .args(["get", "gh", "--show"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert() .success() - .stdout(predicates::str::contains("u")); + .stdout(predicates::str::contains("octocat")); +} + +/// Unit-level: init with --key-file writes the .relkey armor, records +/// second_factor=keyfile in params.json, and gitignores the key file. +#[test] +fn init_keyfile_writes_relkey_and_keyfile_params() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + + // .relkey exists and is armored + let relkey = std::fs::read_to_string(dir.path().join("vault.relkey")).unwrap(); + assert!( + relkey.starts_with("relicario-keyfile-v1\n"), + "relkey must be armored: {relkey}" + ); + + // params.json records the keyfile hint (parse — robust to pretty-print spacing) + let params: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap(), + ) + .unwrap(); + assert_eq!(params["second_factor"], "keyfile"); + + // SECURITY: the in-the-clear .relkey must be gitignored + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!( + gitignore.contains("vault.relkey"), + ".relkey must be gitignored: {gitignore}" + ); } From 23c8bbea655c851866693debc0ddfa6d1f50076d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:43:31 -0400 Subject: [PATCH 07/12] test(cli): assert .relkey never enters git tree + init failure-path coverage + display polish Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/init.rs | 10 ++-- crates/relicario-cli/src/main.rs | 2 +- crates/relicario-cli/tests/keyfile_flows.rs | 61 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/crates/relicario-cli/src/commands/init.rs b/crates/relicario-cli/src/commands/init.rs index ec974f5..e4ee0da 100644 --- a/crates/relicario-cli/src/commands/init.rs +++ b/crates/relicario-cli/src/commands/init.rs @@ -48,7 +48,7 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { // Key-file mode: write the armored secret in the clear. // SECURITY: this file holds the 256-bit secret in the clear and @@ -63,7 +63,7 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { // Image mode: embed into a carrier JPEG, write the reference image. @@ -79,7 +79,7 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { anyhow::bail!( @@ -133,11 +133,11 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { - eprintln!("Key file: {gitignore_name}"); + eprintln!("Key file: {container_display}"); eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); } SecondFactor::Image => { - eprintln!("Reference image: {}", output.display()); + eprintln!("Reference image: {container_display}"); eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); } } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 3ff8f89..21167d5 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -35,7 +35,7 @@ enum Commands { /// Carrier JPEG to embed the secret into (image second factor). #[arg(long)] image: Option, - /// Output path for the reference image (gitignored). + /// Output path for the reference image (gitignored); ignored when --key-file is used. #[arg(long, default_value = "reference.jpg")] output: PathBuf, /// Generate a key-file second factor at this path instead of a reference image (gitignored). diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs index 5369b73..6608938 100644 --- a/crates/relicario-cli/tests/keyfile_flows.rs +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -70,3 +70,64 @@ fn init_keyfile_writes_relkey_and_keyfile_params() { ".relkey must be gitignored: {gitignore}" ); } + +/// SECURITY INVARIANT: the in-the-clear .relkey second factor must NEVER enter +/// the git tree — not in the init commit, and not even after an aggressive `git add -A`. +/// The remote/server must only ever receive opaque ciphertext, never the secret. +#[test] +fn relkey_secret_never_enters_git_tree() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + + let git_tracked = |label: &str| { + let out = Command::new("git") + .current_dir(dir.path()) + .args(["ls-files"]) + .output() + .expect("run git ls-files"); + let files = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!( + !files.contains("vault.relkey"), + "{label}: key file leaked into git tree:\n{files}" + ); + }; + + // Not in the init commit. + git_tracked("after init commit"); + + // Survives an aggressive add -A (gitignore must hold). + let status = Command::new("git") + .current_dir(dir.path()) + .args(["add", "-A"]) + .status() + .expect("run git add -A"); + assert!(status.success(), "git add -A failed"); + git_tracked("after git add -A"); +} + +/// init with no second factor must fail with a clear, actionable error. +#[test] +fn init_without_a_factor_fails() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .failure() + .stderr(predicates::str::contains("either --image")); +} + +/// --image and --key-file are mutually exclusive (clap rejects before the handler). +#[test] +fn init_with_both_factors_fails() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--image", "carrier.jpg", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .failure(); +} From 2203d817ec576396550e126c7267613602d6ea96 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 00:11:50 -0400 Subject: [PATCH 08/12] fix(cli): resolve second factor via shared helper for recovery-qr + backup (key-file parity); wasm equivalence negative control Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/backup.rs | 12 ++++-- .../relicario-cli/src/commands/recovery_qr.rs | 26 ++++++------ crates/relicario-cli/src/session.rs | 40 ++++++++++++------- crates/relicario-cli/tests/keyfile_flows.rs | 18 +++++++++ crates/relicario-wasm/src/lib.rs | 10 +++++ 5 files changed, 75 insertions(+), 31 deletions(-) diff --git a/crates/relicario-cli/src/commands/backup.rs b/crates/relicario-cli/src/commands/backup.rs index 73e91e4..5acace4 100644 --- a/crates/relicario-cli/src/commands/backup.rs +++ b/crates/relicario-cli/src/commands/backup.rs @@ -103,10 +103,14 @@ pub(super) fn cmd_backup_export( // Optional reference image. let image_bytes = if include_image { - let path = match image { - Some(p) => p, - None => crate::session::get_image_path()?, - }; + let pf = crate::session::read_params(&root)?; + if pf.second_factor == relicario_core::SecondFactor::Keyfile { + anyhow::bail!( + "--include-image is not applicable to a key-file vault (it has no reference image); \ + back up your .relkey file directly" + ); + } + let path = match image { Some(p) => p, None => crate::session::get_image_path()? }; Some(fs::read(&path) .with_context(|| format!("failed to read reference image {}", path.display()))?) } else { diff --git a/crates/relicario-cli/src/commands/recovery_qr.rs b/crates/relicario-cli/src/commands/recovery_qr.rs index 7aaf08d..4525a56 100644 --- a/crates/relicario-cli/src/commands/recovery_qr.rs +++ b/crates/relicario-cli/src/commands/recovery_qr.rs @@ -12,21 +12,23 @@ pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> { } fn cmd_recovery_qr_generate() -> Result<()> { - use relicario_core::{generate_recovery_qr, imgsecret}; + use relicario_core::generate_recovery_qr; use zeroize::Zeroizing; - let image_path = crate::session::get_image_path()?; - let image_bytes = std::fs::read(&image_path) - .with_context(|| format!("read reference image {}", image_path.display()))?; - let image_secret = imgsecret::extract(&image_bytes) - .context("extract image secret")?; + let root = crate::helpers::vault_dir()?; + let pf = crate::session::read_params(&root)?; + let secret = crate::session::resolve_second_factor_secret(&root, pf.second_factor)?; - let passphrase = Zeroizing::new( - rpassword::prompt_password("Enter vault passphrase: ") - .context("read passphrase")? - ); + let passphrase = if let Some(p) = crate::test_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new( + rpassword::prompt_password("Enter vault passphrase: ") + .context("read passphrase")? + ) + }; - let payload = generate_recovery_qr(passphrase.as_str(), &image_secret) + let payload = generate_recovery_qr(passphrase.as_str(), &secret) .map_err(|e| anyhow::anyhow!("{e}"))?; use qrcode::{EcLevel, QrCode, render::unicode}; @@ -64,6 +66,6 @@ fn cmd_recovery_qr_unwrap() -> Result<()> { let secret = unwrap_recovery_qr(&bytes, passphrase.as_str()) .map_err(|e| anyhow::anyhow!("{e}"))?; - println!("image_secret: {}", hex::encode(secret.as_ref())); + println!("secret: {}", hex::encode(secret.as_ref())); Ok(()) } diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 6f5a3ce..63acf75 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -36,20 +36,7 @@ impl UnlockedVault { let pf = read_params(&root)?; let params = pf.to_kdf_params(); - let secret: Zeroizing<[u8; 32]> = match pf.second_factor { - SecondFactor::Image => { - let image_path = get_image_path()?; - let image_bytes = fs::read(&image_path) - .with_context(|| format!("failed to read reference image {}", image_path.display()))?; - Zeroizing::new(imgsecret::extract(&image_bytes)?) - } - SecondFactor::Keyfile => { - let kf_path = get_keyfile_path()?; - let kf_bytes = fs::read(&kf_path) - .with_context(|| format!("failed to read key file {}", kf_path.display()))?; - relicario_core::keyfile::keyfile_decode(&kf_bytes).context("invalid key file")? - } - }; + let secret = resolve_second_factor_secret(&root, pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -172,7 +159,7 @@ impl ParamsFile { } } -fn read_params(root: &Path) -> Result { +pub(crate) 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")?; serde_json::from_str(&s).context("failed to parse params.json") @@ -220,6 +207,29 @@ pub fn get_keyfile_path() -> Result { Ok(PathBuf::from(trimmed)) } +/// Resolve the vault's 32-byte second-factor secret per the params hint: +/// extract it from the reference image, or decode it from the key file. +pub(crate) fn resolve_second_factor_secret( + root: &Path, + second_factor: SecondFactor, +) -> Result> { + let _ = root; // retained in the API for future vault-relative resolution + match second_factor { + SecondFactor::Image => { + let image_path = get_image_path()?; + let image_bytes = fs::read(&image_path) + .with_context(|| format!("failed to read reference image {}", image_path.display()))?; + Ok(Zeroizing::new(imgsecret::extract(&image_bytes)?)) + } + SecondFactor::Keyfile => { + let kf_path = get_keyfile_path()?; + let kf_bytes = fs::read(&kf_path) + .with_context(|| format!("failed to read key file {}", kf_path.display()))?; + relicario_core::keyfile::keyfile_decode(&kf_bytes).context("invalid key file") + } + } +} + /// 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<()> { diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs index 6608938..d771a47 100644 --- a/crates/relicario-cli/tests/keyfile_flows.rs +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -121,6 +121,24 @@ fn init_without_a_factor_fails() { .stderr(predicates::str::contains("either --image")); } +/// recovery-qr generate must work for a key-file vault (was broken: it hard-coded image extraction). +#[test] +fn recovery_qr_generate_works_for_keyfile_vault() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + relicario(&dir) + .args(["recovery-qr", "generate"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .success() + .stdout(predicates::str::contains("Recovery QR generated")); +} + /// --image and --key-file are mutually exclusive (clap rejects before the handler). #[test] fn init_with_both_factors_fails() { diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 21eb255..a314e2a 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -670,6 +670,16 @@ mod session_tests { extract(h_key.value()), "key-file path must derive the identical master key as the stego-image path" ); + + // Negative control: a DIFFERENT secret must derive a DIFFERENT master key + // (guards against a hypothetical constant/secret-insensitive KDF regression). + let h_other = unlock_with_secret("correct horse", &[4u8; 32], &salt, params) + .expect("unlock_with_secret other"); + assert_ne!( + extract(h_img.value()), + extract(h_other.value()), + "a different secret must derive a different master key" + ); } #[test] From 110f02e4e3ce320d7bf3df4cd2104ced258450c5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 00:19:55 -0400 Subject: [PATCH 09/12] refactor(cli): drop unused param from resolve_second_factor_secret (simplify pass) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/recovery_qr.rs | 2 +- crates/relicario-cli/src/session.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/commands/recovery_qr.rs b/crates/relicario-cli/src/commands/recovery_qr.rs index 4525a56..f1adc1a 100644 --- a/crates/relicario-cli/src/commands/recovery_qr.rs +++ b/crates/relicario-cli/src/commands/recovery_qr.rs @@ -17,7 +17,7 @@ fn cmd_recovery_qr_generate() -> Result<()> { let root = crate::helpers::vault_dir()?; let pf = crate::session::read_params(&root)?; - let secret = crate::session::resolve_second_factor_secret(&root, pf.second_factor)?; + let secret = crate::session::resolve_second_factor_secret(pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 63acf75..c6fde19 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -36,7 +36,7 @@ impl UnlockedVault { let pf = read_params(&root)?; let params = pf.to_kdf_params(); - let secret = resolve_second_factor_secret(&root, pf.second_factor)?; + let secret = resolve_second_factor_secret(pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -210,10 +210,8 @@ pub fn get_keyfile_path() -> Result { /// Resolve the vault's 32-byte second-factor secret per the params hint: /// extract it from the reference image, or decode it from the key file. pub(crate) fn resolve_second_factor_secret( - root: &Path, second_factor: SecondFactor, ) -> Result> { - let _ = root; // retained in the API for future vault-relative resolution match second_factor { SecondFactor::Image => { let image_path = get_image_path()?; From 13512ed9bfbaaadecd8d12b66389e76fea5ecb6b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 00:39:54 -0400 Subject: [PATCH 10/12] docs: record v0.9.0 org-write spike decision + device-key prereq (PM) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013Hc6Rvdz3DxLucqNtPE2iP --- STATUS.md | 17 ++++++++++++++++- .../2026-06-20-extension-org-gui-design.md | 10 ++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/STATUS.md b/STATUS.md index 3fe69a2..3705e81 100644 --- a/STATUS.md +++ b/STATUS.md @@ -6,7 +6,22 @@ **Last release tagged:** v0.8.1 (`2fa4d68`, 2026-06-20) — org item-type parity + collection-scoped attachments, on top of v0.8.0 enterprise org vault. (v0.7.0 extension restructure; v0.6.0 rolled up Phase 2B / v0.5.1 / 1C-γ / Plan B / management-surfaces / doc-structure.) **Cutting now:** **v0.8.2 — extension vault-creation fix.** Binary-safe `chrome.runtime.sendMessage` transport (`message-binary.ts`) + carrier-image magic-byte guard + setup-wizard router allowlist + vault-tab drawer/lock layout. Extension-only; core/cli/wasm bumped 0.8.1→0.8.2 for tag consistency, `relicario-server` stays 0.1.1. Merged to main `938174b`; `release: v0.8.2` doc/tag commit pending. -**Next track:** **v0.9.0 — extension org GUI + pluggable second factor.** 2 specs + 5 plans committed (`74cee8a`), 0/140 tasks executed — ready for a `mode:"multi"` kickoff. +**In flight:** **v0.9.0 — extension org GUI + pluggable second factor.** Multi-agent lift kicked off 2026-06-25 (5 streams, relay-coordinated). **Org-write scope decision (2026-06-25):** the signed-commit spike proved the signature mechanism (GO), but research found Gitea has **no write Git Data API at any version** (1.26.4 latest; none on roadmap; Contents API can't carry a caller signature) — so org-write ships via **native `git-receive-pack` packfile push (isomorphic-git in the SW), a single universal write path for both Gitea + GitHub**. User chose **build-now in v0.9.0** (not defer). Details in the v0.9.0 lift block below. + +## v0.9.0 lift — in progress (kicked off 2026-06-25) + +Two-track multi-agent lift (5 streams, relay-coordinated; PM + Dev-A..E): +- **Org track (merge order A→B→C):** Dev-A org SW+WASM foundation, Dev-B org read UI, Dev-C org write. +- **Keyfile track (merge order D→E):** Dev-D keyfile core/cli/wasm, Dev-E keyfile extension + positioning pivot. Independent of the org track. + +**Org-write transport decision (2026-06-25) — recorded per the org-GUI spec's spike gate:** +- Spike (Dev-C, `426b82a`; doc `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md`): **signature mechanism is GO** — a SW-built SSHSIG commit (raw `sign_for_git` + manual SSHSIG framing, no new WASM export) passes both `git verify-commit` and `relicario-server verify-org-commit`, incl. item+manifest dual-write + grant/slug authz. Hook matches the **signing-key fingerprint** → `members[].ed25519_pubkey` (committer text free-form, not checked). +- **Transport:** Gitea has **no write Git Data API at any version** (research vs 1.26.4 latest / roadmap / `repo_file.go`; Contents API can't carry a caller signature). The route is **native `git-receive-pack` packfile push via isomorphic-git** in the SW — host-agnostic, works at any Gitea version, preserves the SSHSIG byte-for-byte; extension `host_permissions` avoids the CORS proxy. ~68KB gzip dep. +- **Decision (user):** **build now in v0.9.0** as the **single universal write path for both Gitea + GitHub** (GitHub-only Git Data API approach retired). Plan C re-scoped. **Mini-spike PROVEN GO (2026-06-25):** isomorphic-git `commit({onSign})` embeds the SSHSIG (exact-payload, zero canonicalization risk) and passes `verify-commit` + the **real org pre-receive hook over `git-receive-pack`** — owner-signed accepted, non-member rejected; works at any Gitea version + GitHub. Now building the universal `commitSigned` (A/B-independent); org write handlers + UI held until A+B merge; **`/security-review` mandatory** on the push path before merge. Residual: 1 live-creds push (transport-only, confirm at integration). + +**Tracked follow-up bug (out of v0.9.0 scope unless trivial):** `extension/src/service-worker/gitea.ts` `putBlob` large-blob fallback calls nonexistent Gitea `/git` write endpoints → attachments over `BLOB_THRESHOLD_BYTES` 404 on a Gitea host (pre-existing ~v0.8.2). Found during the spike. + +**Org foundation scope addition (2026-06-26):** Dev-A discovered the extension never persists the device ed25519 private key — `DEVICE_STATE` (WASM `device.rs`) is in-memory only, wiped on every SW restart; `chrome.storage.local` stores only `vaultConfig`/`imageBase64`/`device_name`. The org-GUI spec's assumption of `chrome.storage.local.device_private_key` was wrong; the device-auth key lifecycle was never completed (the extension never used `sign_for_git`). Since `org_unwrap_key` (read) **and** `sign_for_git` (Dev-C write) both need it, A is building **device-key persist+restore (Variant Y)** as a foundation prereq (Task 4.5): encrypt the device key under the personal master key at register, restore into `DEVICE_STATE` at unlock (decrypt **inside** WASM — the private key never crosses to JS), and refactor `org_unwrap_key(keys_blob)` to read `DEVICE_STATE`. Expands the org track scope/timeline; keyfile track (D/E) unaffected. ## What landed on main since the v0.5.0 version bump diff --git a/docs/superpowers/specs/2026-06-20-extension-org-gui-design.md b/docs/superpowers/specs/2026-06-20-extension-org-gui-design.md index c7791dd..d53b7f7 100644 --- a/docs/superpowers/specs/2026-06-20-extension-org-gui-design.md +++ b/docs/superpowers/specs/2026-06-20-extension-org-gui-design.md @@ -30,6 +30,16 @@ Therefore org **write** from the extension must construct and push a **signed co The spike result is recorded back into this spec and `STATUS.md` before A3 build work starts. +### Spike result + transport decision (resolved 2026-06-25) + +The spike (Dev-C, commit `426b82a`; evidence in `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md`) returned **GO on the signature mechanism**: a SW-built SSHSIG commit (raw `sign_for_git` + manual SSHSIG framing — **no new WASM export**) passes both `git verify-commit` and `relicario-server verify-org-commit`, including the item+manifest dual-write with grant/slug authz. The hook matches the **signing-key fingerprint → `members[].ed25519_pubkey`** (committer/author text is free-form, not checked — this corrects the "committer identity must match" assumption). + +**Transport finding (corrects this section's planned approach):** the GitHub-style Git Data API path is **GitHub-only**. Research (vs Gitea 1.26.4 / roadmap / the `repo_file.go` structs / open issues #30955, #22100) confirms **no Gitea version exposes a write Git Data API or accepts a caller-supplied commit signature** (the Contents API signs with the instance key or not at all), and none is planned. So A3 does **not** use the host REST API. Org write ships via **native `git-receive-pack` packfile push (isomorphic-git in the SW)** — host-agnostic, works at any Gitea version, preserves the SSHSIG byte-for-byte; the extension's `host_permissions` removes the usual CORS-proxy requirement. This becomes the **single universal write path for both Gitea and GitHub** (~68KB gzip dependency). + +**Decision (user, 2026-06-25): build the universal packfile-push write path now, in v0.9.0** — not the deferred fallback. A3 (Plan C) is re-scoped: a small SSHSIG-through-isomorphic-git mini-spike, then the universal `commitSigned`, then the write handlers + UI (held until A0–A2 land). A focused `/security-review` on the push path is mandatory before merge. + +**Latent bug noted (separate follow-up):** `extension/src/service-worker/gitea.ts` `putBlob` calls nonexistent Gitea `/git` write endpoints — large attachments would 404 on a Gitea host (pre-existing ~v0.8.2). + ## Architecture An org vault is a second git repo alongside the personal vault, cryptographically isolated (org spec § Architecture). The org master key is a random 256-bit key, wrapped per member via ECIES (X25519 + XChaCha20-Poly1305) to their device ed25519 key. The extension mirrors the CLI: unwrap the org key with the device private key, decrypt items exactly as the personal vault does — but with the org key, not the Argon2id-derived personal key. From d6492033351cdf943ad120fe8bf8fee5c12deddd Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 00:45:50 -0400 Subject: [PATCH 11/12] docs(status): Dev-D keyfile core/cli/wasm merged (588495f); keyfile-minors follow-up tracked Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013Hc6Rvdz3DxLucqNtPE2iP --- STATUS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/STATUS.md b/STATUS.md index 3705e81..fa0f584 100644 --- a/STATUS.md +++ b/STATUS.md @@ -14,6 +14,8 @@ Two-track multi-agent lift (5 streams, relay-coordinated; PM + Dev-A..E): - **Org track (merge order A→B→C):** Dev-A org SW+WASM foundation, Dev-B org read UI, Dev-C org write. - **Keyfile track (merge order D→E):** Dev-D keyfile core/cli/wasm, Dev-E keyfile extension + positioning pivot. Independent of the org track. +**Merged to main so far:** Dev-D keyfile core/cli/wasm — `588495f` (2026-06-26). Two-review-clean (PM diff read + independent security subagent; PASS, no Critical/Important); merged tree green (workspace `cargo test` incl. the non-tautological equivalence test, `clippy -D warnings`). 3 minor follow-ups tracked for a `fix/v0.9.0-keyfile-minors` branch before the tag: (1) `keyfile_encode` → `Zeroizing>`; (2) test the `backup --include-image` clean-error on keyfile vaults; (3) integration test for malformed-keyfile vs wrong-secret at unlock. Plus the deferred `backup --include-keyfile` sibling. Dev-E green-lit (next in keyfile track). + **Org-write transport decision (2026-06-25) — recorded per the org-GUI spec's spike gate:** - Spike (Dev-C, `426b82a`; doc `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md`): **signature mechanism is GO** — a SW-built SSHSIG commit (raw `sign_for_git` + manual SSHSIG framing, no new WASM export) passes both `git verify-commit` and `relicario-server verify-org-commit`, incl. item+manifest dual-write + grant/slug authz. Hook matches the **signing-key fingerprint** → `members[].ed25519_pubkey` (committer text free-form, not checked). - **Transport:** Gitea has **no write Git Data API at any version** (research vs 1.26.4 latest / roadmap / `repo_file.go`; Contents API can't carry a caller signature). The route is **native `git-receive-pack` packfile push via isomorphic-git** in the SW — host-agnostic, works at any Gitea version, preserves the SSHSIG byte-for-byte; extension `host_permissions` avoids the CORS proxy. ~68KB gzip dep. From 2c5af671d02880f2e845fa9b967a8e49798ce11d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 21:29:56 -0400 Subject: [PATCH 12/12] docs(coordination): v0.9.0 dev resume prompts after ~21h stall (D merged; A/B/C/E resume) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013Hc6Rvdz3DxLucqNtPE2iP --- .../coordination/v0.9.0-dev-a-resume.md | 57 ++++++++++++++++++ .../coordination/v0.9.0-dev-b-resume.md | 48 +++++++++++++++ .../coordination/v0.9.0-dev-c-resume.md | 50 ++++++++++++++++ .../coordination/v0.9.0-dev-d-resume.md | 45 ++++++++++++++ .../coordination/v0.9.0-dev-e-resume.md | 59 +++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 docs/superpowers/coordination/v0.9.0-dev-a-resume.md create mode 100644 docs/superpowers/coordination/v0.9.0-dev-b-resume.md create mode 100644 docs/superpowers/coordination/v0.9.0-dev-c-resume.md create mode 100644 docs/superpowers/coordination/v0.9.0-dev-d-resume.md create mode 100644 docs/superpowers/coordination/v0.9.0-dev-e-resume.md diff --git a/docs/superpowers/coordination/v0.9.0-dev-a-resume.md b/docs/superpowers/coordination/v0.9.0-dev-a-resume.md new file mode 100644 index 0000000..f7f2433 --- /dev/null +++ b/docs/superpowers/coordination/v0.9.0-dev-a-resume.md @@ -0,0 +1,57 @@ +# Dev A — RESUME Prompt (v0.9.0 Plan A, org foundation) + +Paste everything below the `---` into a **fresh** Claude Code terminal. + +--- + +You are **RESUMING** Plan A (org foundation: SW + WASM) for v0.9.0. The lift paused ~21h ago; you are a fresh session with no relay history. Your worktree, branch, and commits **already exist** — do NOT recreate the worktree and do NOT restart from Task 1. + +## Resume setup (replaces the original Setup block) + +```bash +cd /home/alee/Sources/relicario.v0.9.0-dev-a +pwd # must print /home/alee/Sources/relicario.v0.9.0-dev-a +git status # expect your branch, clean-ish tree +git log --oneline -8 # confirm Tasks 1-4 are committed (down to f5336e3) +git fetch origin +git merge origin/main # main now has Dev-D's keyfile merge (d649203). EXPECT trivial additive + # conflicts in crates/relicario-wasm/src/lib.rs and extension/src/wasm.d.ts + # (D added keyfile_* / unlock_with_secret next to your org_* fns) — keep BOTH. +``` + +ALL work stays in `/home/alee/Sources/relicario.v0.9.0-dev-a`. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.9.0-dev-a` (project memory — a header is not enough). + +## Read for full context + +- `docs/superpowers/coordination/v0.9.0-dev-a-prompt.md` — your ORIGINAL prompt: use it for ROLE, the RELAY protocol + shim, the STATUS/QUESTION block formats, polling cadence, specs/plan paths. **IGNORE its Setup block and its "begin Task 1."** +- `docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md` — your plan. +- `CLAUDE.md`. + +The relay is up on `localhost:7331` (shim: `cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-a"}'`). The PM (me) is live — poll before/after every subagent and at task boundaries. + +## WHERE YOU ARE — already done, do NOT redo (commits, local-only, not pushed) + +- **Task 1** `org_unwrap_key` — DONE. Device-key form resolved EMPIRICALLY = **OpenSSH PEM blob** (param `device_private_openssh`). zeroized `extract_ed25519_seed` helper lifted into `relicario-core::device`, reused by `sign()`. (NOTE: this signature changes in Task 4.5 below.) +- **Task 2** org manifest over WASM — DONE. The org manifest is a **dedicated `OrgManifest { schema_version, entries: OrgManifestEntry[] }}`** type (NOT the personal `ManifestEntry`). You exposed `org_manifest_decrypt` + `org_manifest_encrypt` over WASM (wrapping `vault::{decrypt,encrypt}_org_manifest`) and mirrored `OrgManifest`/`OrgManifestEntry` faithfully/LEAN into `shared/types.ts` (`{id, type, title, tags[], modified, trashed_at?, collection}` — `collection` REQUIRED; NO personal-only fields). +- **Task 3** multi-context session — DONE. `clearAll` is **best-effort** (each `handle.free()` in its own try/catch, null personal + clear orgs + reset context REGARDLESS, never throws); `setOrg` frees the displaced handle; lock + inactivity timer both zero ALL handles; `session.test.ts` updated to the best-effort contract with a WHY rationale comment + a multi-handle test. Org key NEVER persisted. +- **Task 4** org config + `org_list_configs` — DONE. Wired in all THREE places (PopupMessage union + POPUP_ONLY_TYPES + handler arm); `org_list_configs` returns `OrgConfigSummary {orgId, displayName}` ONLY (no token leak, asserted). Minor cleanup pending: `saveOrgConfigs()` is unused — remove-or-justify at your simplify pass. + +## KEY PM DECISIONS during the lift (absorb — you have no relay history) + +- **Grant filter (Task 5) = Option A, SOFT phase-1 filter.** Add a WASM `org_manifest_decrypt_filtered(handle, encrypted, granted[])` that decrypts AND grant-filters in core, so ungranted entries never cross to JS — used by the READ path only. Single-source the filter: extract a core helper `filter_by_collections(&[String])` that BOTH `OrgManifest::filter_for_member` AND the new WASM fn delegate to (no filter logic reimplemented in WASM/TS). **KEEP the full unfiltered `org_manifest_decrypt`** — Dev-C's writes need the FULL manifest (re-encrypting a filtered subset would wipe ungranted entries). Comment that phase-1 is a soft UX filter (the member holds the single org key), NOT crypto isolation — defense-in-depth only. + +## YOUR REMAINING WORK (you are the org-track critical path — B and C wait on your merge) + +- **Task 4.5 — NEW foundation prerequisite (Variant Y): device-key persist + restore.** DISCOVERED GAP: the extension never persists the device ed25519 private key — `DEVICE_STATE` (WASM `device.rs`) is in-memory only, wiped on every SW restart; `chrome.storage.local` stores only `vaultConfig`/`imageBase64`/`device_name`. So `org_unwrap_key` (and `sign_for_git` for Dev-C) have no key after a restart. Build (private key NEVER crosses to JS): + 1. WASM: at register, encrypt the device private key under the current session **master key** and return CIPHERTEXT to JS; JS persists it as `device_key_enc` in `chrome.storage.local`. + 2. WASM `restore_device_key(session_handle, encrypted)`: decrypt under the master key + repopulate `DEVICE_STATE` (decrypt INSIDE WASM). Wire it at unlock. + 3. Refactor `org_unwrap_key` → `org_unwrap_key(keys_blob)` — read the seed from `DEVICE_STATE` internally; DROP the `device_private_openssh` param. + 4. Migration: if `device_key_enc` is absent at unlock (existing installs), handle gracefully (re-register + persist). + 5. Tests: persist→restore round-trip; survives-SW-restart simulation; `org_unwrap_key` works post-restore; `device_key_enc` in storage is NOT plaintext. (This ALSO unblocks Dev-C's production signing.) +- **Task 5 — org read core**: `openOrg` (read public `members.json`/`collections.json`, match this device by ed25519 fingerprint → grants, `org_unwrap_key`, fetch + `org_manifest_decrypt_filtered` to grants, cache); `getOrgItem` (`items//.enc` → `item_decrypt`); `listOrgCollections`. Offline: on git network error, serve last-cached manifest read-only + `offline: true`. +- **Task 6 — `org_switch` + read messages**: `org_switch {context}` (sets context, caches `OrgHandleState`, offline-aware), `org_list_items` (grant-filtered `OrgManifestEntry[]`), `org_get_item {id}` (`Item`), `org_list_collections` (`Collection {slug, display_name}`). Wire all in the three places. +- Then full validation (cargo test wasm/core, wasm32 build, clippy `-D warnings`, `cd extension && npx vitest run src/service-worker/ && npm run build:all`), simplify pass, **push** `feature/v0.9.0-dev-a-org-foundation`, post `Status: REVIEW-READY`. The PM merges you FIRST in the org track (your merge is Dev-B's green light). + +## First action + +`cd` into the worktree, run `git log --oneline -8` to confirm state, `read_messages(for="dev-a")` to get my resume directive, then post a `## STATUS UPDATE` with `Status: IN-PROGRESS`, `Task: 4.5 device-key persist`, confirming RESUMED + main merged. Then continue via `superpowers:subagent-driven-development`. diff --git a/docs/superpowers/coordination/v0.9.0-dev-b-resume.md b/docs/superpowers/coordination/v0.9.0-dev-b-resume.md new file mode 100644 index 0000000..065d94c --- /dev/null +++ b/docs/superpowers/coordination/v0.9.0-dev-b-resume.md @@ -0,0 +1,48 @@ +# Dev B — RESUME Prompt (v0.9.0 Plan B, org read UI) + +Paste everything below the `---` into a **fresh** Claude Code terminal. + +--- + +You are **RESUMING** Plan B (org read UI) for v0.9.0. The lift paused ~21h ago; you are a fresh session with no relay history. Your worktree, branch, and commits **already exist** — do NOT recreate the worktree. + +**You are still HELD:** your integration waits on **Dev-A merging to main**. A has not merged yet (A is finishing the org foundation + a new device-key prerequisite). So your job on resume is to re-establish state, stay held, and execute the moment the PM posts "Dev-A merged." + +## Resume setup (replaces the original Setup block) + +```bash +cd /home/alee/Sources/relicario.v0.9.0-dev-b +pwd # must print /home/alee/Sources/relicario.v0.9.0-dev-b +git status +git log --oneline -5 # confirm your failing-test scaffold is committed (down to 20357d6) +git fetch origin +git merge origin/main # main has Dev-D's keyfile merge; integrate it (should be clean for your files) +``` + +ALL work stays in `/home/alee/Sources/relicario.v0.9.0-dev-b`. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.9.0-dev-b`. + +## Read for full context + +- `docs/superpowers/coordination/v0.9.0-dev-b-prompt.md` — ORIGINAL prompt (ROLE, RELAY, STATUS formats, specs/plan). **IGNORE its Setup block.** +- `docs/superpowers/plans/2026-06-20-v0.9.0-org-b-read-ui.md` — your plan. + +Relay up on `localhost:7331` (shim: `cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-b"}'`). PM is live. + +## WHERE YOU ARE — already done (commits, local-only) + +- Failing-test scaffold for Plan B Tasks 1-5 — committed (RED, 6 fail / 3 pass), refined to the `OrgManifestEntry` shape (down to 20357d6). No real implementation yet (correctly held). + +## KEY CONTRACT FACTS (absorb — confirmed during the lift) + +- `org_list_items` returns **`OrgManifestEntry[]`** (the dedicated type, NOT personal `ManifestEntry`): `{id, type, title, tags[], modified, trashed_at?, collection}` — `collection` REQUIRED. **Import `OrgManifest`/`OrgManifestEntry` from `shared/types.ts`** (Dev-A's mirror, on main after A merges) — do NOT hand-roll. +- `OrgManifestEntry` is **leaner** than personal `ManifestEntry` (no `attachment_summaries`/`icon_hint`/`favorite`/`group`). **Normalize `OrgManifestEntry → ManifestEntry` at the renderer boundary** (`attachment_summaries:[]`, `favorite:false`, type cast) before `buildRowsHtml` — same renderer, NO per-type fork. Accepted consequence: org list rows won't show the 📎 attachment indicator (detail view via `org_get_item → Item` still has attachments). +- Other contract entries unchanged: `org_list_configs → OrgConfigSummary[]`, `org_switch {context} → {context, offline}`, `org_get_item {id} → Item`, `org_list_collections → Collection {slug, display_name}`. Errors snake_case via `humanizeError` (incl. `not_an_org_member`, `vault_locked`, and a possible `device_key_unavailable` — your generic handler covers it, no scaffold change). +- This plan is READ-ONLY: hide write affordances (Dev-C adds them later). + +## YOUR REMAINING WORK (you merge SECOND in the org track — after A) + +All 5 implementation tasks, **executed only AFTER the PM posts "Dev-A merged to main"** (then rebase main, flip your RED scaffold green task-by-task with subagent-driven-development + 2-stage review): Task 1 PopupState org fields + `org-context` message helper; Task 2 org switcher (popup header + vault sidebar); Task 3 list/detail consume the context-aware source; Task 4 collection facet; Task 5 offline banner. Then `cd extension && npx vitest run && npm run build:all`, simplify, push, REVIEW-READY. + +## First action + +`cd` into the worktree, `git log --oneline -5`, `read_messages(for="dev-b")` for my resume directive, post a `## STATUS UPDATE` (`Status: IN-PROGRESS`, `Task: scaffold/held`) confirming RESUMED + main merged + **HOLD acknowledged** (integration waits on Dev-A merge). Then poll the relay for the "Dev-A merged" green light; do not integrate before it. diff --git a/docs/superpowers/coordination/v0.9.0-dev-c-resume.md b/docs/superpowers/coordination/v0.9.0-dev-c-resume.md new file mode 100644 index 0000000..febd7b0 --- /dev/null +++ b/docs/superpowers/coordination/v0.9.0-dev-c-resume.md @@ -0,0 +1,50 @@ +# Dev C — RESUME Prompt (v0.9.0 Plan C, org write) + +Paste everything below the `---` into a **fresh** Claude Code terminal. + +--- + +You are **RESUMING** Plan C (org write) for v0.9.0. The lift paused ~21h ago; you are a fresh session with no relay history. Your worktree, branch, and commits **already exist** — do NOT recreate the worktree, do NOT re-run the spike, do NOT rebuild `commitSigned`. + +**You are still HELD:** your write handlers/UI (Tasks 3-5) wait on **both Dev-A AND Dev-B merging to main** (they consume A's context model + B's read surfaces). Neither has merged yet. On resume: re-establish state, stay held, execute when the PM posts "Dev-A AND Dev-B merged." + +## Resume setup (replaces the original Setup block) + +```bash +cd /home/alee/Sources/relicario.v0.9.0-dev-c +pwd # must print /home/alee/Sources/relicario.v0.9.0-dev-c +git status +git log --oneline -8 # confirm the spike + universal commitSigned are committed (down to 2206813) +git fetch origin +git merge origin/main # main has Dev-D's keyfile merge; integrate it +``` + +ALL work stays in `/home/alee/Sources/relicario.v0.9.0-dev-c`. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.9.0-dev-c`. + +## Read for full context + +- `docs/superpowers/coordination/v0.9.0-dev-c-prompt.md` — ORIGINAL prompt (ROLE, RELAY, STATUS formats, specs/plan). **IGNORE its Setup block, and note the spike is already DONE.** +- `docs/superpowers/plans/2026-06-20-v0.9.0-org-c-write.md` — your plan. +- `docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md` — your committed spike result. + +Relay up on `localhost:7331` (shim: `cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-c"}'`). PM is live. + +## WHERE YOU ARE — already done, do NOT redo (commits, local-only) + +- **Task 1 spike** — DONE, verdict GO. Signature MECHANISM proven (raw `sign_for_git` + manual SSHSIG framing, no new WASM export); hook matches the **signing-key fingerprint → `members[].ed25519_pubkey`** (committer/author text is free-form, not checked). +- **Task 2 universal `commitSigned`** — DONE + security-clean (down to 2206813). Built `sshsig.ts` (golden-vector-gated), dep-FREE in-memory `mem-fs.ts` (no memfs/IndexedDB — honors org-data-never-persisted), `git-push.ts` (isomorphic-git clone→commit{onSign}→`git-receive-pack` push), `commitSigned` on `GitHost`/Gitea/GitHub. Proven end-to-end against the real org pre-receive hook (member ACCEPTED / non-member REJECTED). Your own push-path `/security-review`: clean. + +## KEY PM DECISIONS during the lift (absorb) + +- **Build-now (universal path):** org write ships via the isomorphic-git packfile-push you built — host-agnostic, both Gitea + GitHub. (The Gitea REST git-data API does not exist at any version; packfile-push is the only route. This is settled.) +- **ORG-MANIFEST-MUTATION LANDMINE (critical for Tasks 3-5):** for `org_add/update/delete`, you MUST — (1) decrypt the **FULL** manifest via `wasm.org_manifest_decrypt` (the UNFILTERED fn, NOT `org_manifest_decrypt_filtered`, which is Dev-B's read-display path); (2) mutate the `entries[]` array (add/update/remove one `OrgManifestEntry`); (3) re-encrypt the FULL manifest via `wasm.org_manifest_encrypt`; (4) push `items//.enc` + `manifest.enc` in ONE signed commit. Re-encrypting a grant-filtered subset would WIPE every ungranted collection's entries — silent data loss. org ITEMS stay personal `Item`s via `item_encrypt`. +- **Device key:** `sign_for_git` works in production once Dev-A's device-key persist/restore (Task 4.5) lands — A owns it; do NOT build your own key handling, just assume `DEVICE_STATE` is populated after unlock. +- **Live residual (non-blocking):** one real push to `git.adlee.work`/GitHub with tokens to confirm per-host Basic-auth shape — do this at integration, don't block the build. + +## YOUR REMAINING WORK (you merge THIRD in the org track — after A and B) + +**Only AFTER the PM posts "Dev-A AND Dev-B merged":** rebase main, then Task 3 (org_add/update/delete SW handlers via `commitSigned` + the manifest landmine + client-side grant check), Task 4 (org write UI: collection picker, edit/delete, offline-gated), Task 5 (write acceptance tests: signature attached, grant refused, dual-write, offline blocks). MANDATORY `/security-review` on the full write path before REVIEW-READY. Then `cd extension && npx vitest run && npm run build:all`, simplify, push, REVIEW-READY. + +## First action + +`cd` into the worktree, `git log --oneline -8`, `read_messages(for="dev-c")` for my resume directive, post a `## STATUS UPDATE` (`Status: IN-PROGRESS`, `Task: 2 done / 3-5 held`) confirming RESUMED + main merged + **HOLD acknowledged** (Tasks 3-5 wait on A+B merge). Then poll for the "A+B merged" green light; do not integrate before it. diff --git a/docs/superpowers/coordination/v0.9.0-dev-d-resume.md b/docs/superpowers/coordination/v0.9.0-dev-d-resume.md new file mode 100644 index 0000000..1ebe40a --- /dev/null +++ b/docs/superpowers/coordination/v0.9.0-dev-d-resume.md @@ -0,0 +1,45 @@ +# Dev D — RESUME Prompt (v0.9.0 keyfile MINORS follow-up) + +Paste everything below the `---` into a **fresh** Claude Code terminal. + +--- + +You are **RESUMING** the Dev-D keyfile stream for v0.9.0 — but your 5 plan tasks are **already MERGED to main** (`d649203`, two-review-clean). Your only remaining job is a small **minors follow-up** flagged by the merge-time security review, on a fresh branch off current main. + +## Resume setup + +```bash +cd /home/alee/Sources/relicario +git fetch origin +git checkout main +git pull # main should be at d649203 (your keyfile core/cli/wasm is here) +git log --oneline -4 # confirm the keyfile merge (588495f) is present +git worktree add /home/alee/Sources/relicario.v0.9.0-dev-d-minors -b fix/v0.9.0-keyfile-minors +cd /home/alee/Sources/relicario.v0.9.0-dev-d-minors +pwd # must print /home/alee/Sources/relicario.v0.9.0-dev-d-minors +``` + +(Your ORIGINAL worktree `/home/alee/Sources/relicario.v0.9.0-dev-d` still holds your merged branch — leave it.) Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.9.0-dev-d-minors`. + +## Read for full context + +- `docs/superpowers/coordination/v0.9.0-dev-d-prompt.md` — ORIGINAL prompt (ROLE, RELAY, STATUS formats). **IGNORE its Setup block and its 5 tasks — they're merged.** +- Your merged code: `crates/relicario-core/src/keyfile.rs`, `crates/relicario-cli/src/{session.rs,commands/{init,backup,recovery_qr}.rs}`, `crates/relicario-cli/tests/keyfile_flows.rs`, `crates/relicario-wasm/src/lib.rs`. + +Relay up on `localhost:7331` (shim: `cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-d"}'`). PM is live. + +## YOUR WORK — the 3 minors (security-review findings, all non-blocking, now to be closed before the v0.9.0 tag) + +1. **Zeroize `keyfile_encode` output** — `crates/relicario-core/src/keyfile.rs`: `keyfile_encode` returns a plain `Vec` whose base64'd secret lands in non-zeroized heap. Return `Zeroizing>` (derefs fine for `fs::write`). One-liner, for Zeroizing-discipline consistency. Update callers + the WASM binding if the type change ripples. +2. **Test the backup keyfile-vault error** — add a `keyfile_flows` case asserting `backup --include-image` on a key-file vault fails with the clear actionable message (currently correct but untested). +3. **Integration test: malformed vs wrong** — add a test locking the two distinct paths: a malformed key file → `invalid_key_file` error; a well-formed-but-wrong secret → the opaque `decryption failed` AEAD error (no which-factor oracle). + +**Optional (defer if it grows): `backup --include-keyfile`** — a sibling to `--include-image` that bundles the `.relkey` into a backup. Flagged as a fast-follow; only do it if quick, else leave it for a later lift and note so. + +## Validation + ship + +`cargo test -p relicario-core -p relicario-cli`, `cargo clippy --all-targets -- -D warnings`, `cd /home/alee/Sources/relicario.v0.9.0-dev-d-minors/extension && npm run build:all` (the Zeroizing change touches a WASM-exported fn — type-check it). Simplify pass. Then `git push -u origin fix/v0.9.0-keyfile-minors` and post `Status: REVIEW-READY`. The PM merges it before the tag. + +## First action + +After setup, `read_messages(for="dev-d")` for my resume directive, post a `## STATUS UPDATE` (`Status: IN-PROGRESS`, `Task: keyfile-minors`) confirming the minors branch is up, then knock out the 3 minors via `superpowers:subagent-driven-development` (or directly — they're small). diff --git a/docs/superpowers/coordination/v0.9.0-dev-e-resume.md b/docs/superpowers/coordination/v0.9.0-dev-e-resume.md new file mode 100644 index 0000000..12f8858 --- /dev/null +++ b/docs/superpowers/coordination/v0.9.0-dev-e-resume.md @@ -0,0 +1,59 @@ +# Dev E — RESUME Prompt (v0.9.0 Plan 5, keyfile extension + positioning) + +Paste everything below the `---` into a **fresh** Claude Code terminal. + +--- + +You are **RESUMING** Plan 5 (keyfile extension + positioning pivot) for v0.9.0. The lift paused ~21h ago; you are a fresh session with no relay history. Your worktree, branch, and commits **already exist** — do NOT recreate the worktree and do NOT restart from Task 1. + +**You are UNBLOCKED:** Dev-D (keyfile core/cli/wasm) is MERGED to main — the real WASM bindings are available now. + +## Resume setup (replaces the original Setup block) + +```bash +cd /home/alee/Sources/relicario.v0.9.0-dev-e +pwd # must print /home/alee/Sources/relicario.v0.9.0-dev-e +git status +git log --oneline -6 # confirm Task 1 (wizard) + Task 6 (docs) + scaffold are committed (down to c79ff1c) +git fetch origin +git merge origin/main # PULLS IN Dev-D's keyfile WASM bindings (keyfile_encode/decode, unlock_with_secret) + # + the SecondFactor params hint. Resolve any trivial wasm.d.ts conflicts (keep both). +``` + +ALL work stays in `/home/alee/Sources/relicario.v0.9.0-dev-e`. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.9.0-dev-e`. + +## Read for full context + +- `docs/superpowers/coordination/v0.9.0-dev-e-prompt.md` — your ORIGINAL prompt: ROLE, RELAY protocol + shim, STATUS/QUESTION formats, polling cadence, specs/plan paths. **IGNORE its Setup block and "begin Task 1."** +- `docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-ext-positioning.md` — your plan. +- `CLAUDE.md`. + +Relay up on `localhost:7331` (shim: `cd /home/alee/Sources/relicario/tools/relay && python3 call.py read_messages '{"for":"dev-e"}'`). PM is live — poll before/after every subagent + at task boundaries. + +## WHERE YOU ARE — already done, do NOT redo (commits, local-only) + +- **Task 1** wizard second-factor choice (Reference Image | Key File) — DONE. +- **Task 6** positioning docs (README re-lead on the two-factor-KDF thesis; stego framed as one option) — DONE (1591fc7..9e19949). **Will need reconciliation with the FORMATS/SECURITY deltas below.** +- Scaffold types committed (`create_vault` `secondFactor` request + `relkeyBytes` response) — c79ff1c. + +## KEY FACTS from Dev-D (now on main — authoritative) + +- WASM: `keyfile_encode(Uint8Array)->Uint8Array`, `keyfile_decode(Uint8Array)->Uint8Array` (throws on bad armor/length), `unlock_with_secret(passphrase:string, secret:Uint8Array[32], salt:Uint8Array[32], params_json:string)->SessionHandle`. +- **Pass the SAME `params_json` you already give `unlock()` — `second_factor` does NOT affect the KDF** (that is the equivalence guarantee; D's test proves byte-identical master keys). +- `params.json` is a nested **ParamsFile** wrapper; `second_factor` is a **TOP-LEVEL** field on it (`image|keyfile`, absent ⇒ image). Read it pre-unlock to pick the image-picker vs key-file-picker. +- Armor: `relicario-keyfile-v1\n` + base64(32 bytes) + `\n`; ext `.relkey`; the secret is IN THE CLEAR. + +## YOUR REMAINING WORK (you merge after D — which is done — so you can REVIEW-READY as soon as you're complete) + +- **Task 2** SW `create_vault` key-file branch: generate the 32-byte secret (`crypto.getRandomValues`), `unlock_with_secret`, write `params.json` `second_factor: "keyfile"`, store `keyfileBase64` in `chrome.storage.local` (exactly like `imageBase64`), return `{ relkeyBytes }` (base64-enveloped via `shared/message-binary.ts`). +- **Task 3** wizard key-file download: trigger a `vault.relkey` download from the `create_vault` response + "save this key file" copy. +- **Task 4** SW `unlock` branches on the params hint: if `keyfile`, load `keyfileBase64` → `keyfile_decode` → `unlock_with_secret`; else the image path. Malformed → `invalid_key_file`. +- **Task 5** attach-mode key-file picker: detect `second_factor` in the probe; render a `.relkey` input when `keyfile`; store `keyfileBase64`; verify via `unlock_with_secret`. +- **Doc deltas to LAND (Dev-D deliberately left these to you — reconcile with your Task 6):** `FORMATS.md` (.relkey armor `relicario-keyfile-v1` + base64(32) + the params top-level `second_factor`), `SECURITY.md` (.relkey / `keyfileBase64` is the second factor IN THE CLEAR, gitignored + never pushed on CLI, local-only in `chrome.storage` on the extension — server only ever sees opaque ciphertext; same posture as the reference JPEG, NOT encrypted), `docs/CRYPTO.md` (pluggable-transport framing), `docs/user_docs/` if any extension-facing keyfile usage. +- **Verify** the extension recovery-QR works for a key-file vault — it should already work because `unlock_with_secret` stores the secret in the WASM session and `generate_recovery_qr` reads from the session (NOT re-resolved from `imageBase64`); confirm it does not assume an image. If it re-resolves from the image, fix it. +- **MANDATORY** `/security-review` on the key-file path before REVIEW-READY (equivalence-to-stego, armor parsing, in-the-clear-storage honesty, no oracle difference). Binary crosses `chrome.runtime.sendMessage` base64-enveloped only. +- Then full validation (`cd extension && npx vitest run && npm run build:all`), simplify pass, **push** `feature/v0.9.0-dev-e-keyfile-ext`, post `Status: REVIEW-READY`. + +## First action + +`cd` into the worktree, `git log --oneline -6`, `read_messages(for="dev-e")` for my resume directive, post a `## STATUS UPDATE` (`Status: IN-PROGRESS`, `Task: 2 create_vault keyfile`) confirming RESUMED + main merged + D bindings present, then continue via `superpowers:subagent-driven-development`.