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/init.rs b/crates/relicario-cli/src/commands/init.rs index f6612b2..e4ee0da 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, container_display, second_factor): (String, 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, kf_path.display().to_string(), 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, output.display().to_string(), 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: {container_display}"); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + } + SecondFactor::Image => { + eprintln!("Reference image: {container_display}"); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + } + } Ok(()) } diff --git a/crates/relicario-cli/src/commands/recovery_qr.rs b/crates/relicario-cli/src/commands/recovery_qr.rs index 7aaf08d..f1adc1a 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(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/main.rs b/crates/relicario-cli/src/main.rs index 3e38e43..21167d5 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, - /// Output path for the reference image (gitignored). + image: Option, + /// 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). + #[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 0e4f0ed..c6fde19 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; @@ -28,16 +28,15 @@ 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 = resolve_second_factor_secret(pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -50,7 +49,7 @@ impl UnlockedVault { let master_key = derive_master_key( passphrase.as_bytes(), - &image_secret, + &secret, &salt, ¶ms, )?; @@ -119,6 +118,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)] @@ -131,7 +135,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 { @@ -142,6 +146,7 @@ impl ParamsFile { }, aead: "xchacha20poly1305".into(), salt_path: ".relicario/salt".into(), + second_factor, } } @@ -154,11 +159,10 @@ 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")?; - 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. @@ -180,6 +184,50 @@ 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)) +} + +/// 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( + second_factor: SecondFactor, +) -> Result> { + 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<()> { @@ -237,7 +285,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"); @@ -248,6 +296,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-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs new file mode 100644 index 0000000..d771a47 --- /dev/null +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -0,0 +1,151 @@ +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. +#[test] +fn init_keyfile_then_unlock_keyfile_round_trips() { + let dir = tempfile::tempdir().unwrap(); + + // init with a key file + 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", "octocat", "--password", "hunter2"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .success(); + + // 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("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}" + ); +} + +/// 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")); +} + +/// 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() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--image", "carrier.jpg", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .failure(); +} 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/keyfile.rs b/crates/relicario-core/src/keyfile.rs new file mode 100644 index 0000000..c84cf73 --- /dev/null +++ b/crates/relicario-core/src/keyfile.rs @@ -0,0 +1,87 @@ +//! 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 = 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)] +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 + } + #[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()); + } +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 5c1e607..f362ec2 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}; @@ -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}; diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f6c6b80..a314e2a 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,76 @@ 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" + ); + + // 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] 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; }