merge: feature/v0.9.0-dev-d-keyfile-core (v0.9.0 Dev-D keyfile core/cli/wasm)

This commit is contained in:
adlee-was-taken
2026-06-26 00:39:55 -04:00
11 changed files with 572 additions and 54 deletions

View File

@@ -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 {

View File

@@ -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<PathBuf>, output: PathBuf, key_file: Option<PathBuf>) -> Result<()> {
use std::fs;
use rand::{rngs::OsRng, RngCore};
use relicario_core::{
derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
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 <carrier> (with --output) or --key-file <path>"
)
}
};
// 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, &params)?;
let master_key = derive_master_key(passphrase.as_bytes(), &secret, &salt, &params)?;
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(&params))?,
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(
&params,
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(())
}

View File

@@ -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(())
}

View File

@@ -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<PathBuf>,
/// 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<PathBuf>,
},
/// 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),

View File

@@ -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<Self> {
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,
&params,
)?;
@@ -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<KdfParams> {
pub(crate) fn read_params(root: &Path) -> Result<ParamsFile> {
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<PathBuf> {
Ok(PathBuf::from(trimmed))
}
/// Locate the key file via `RELICARIO_KEYFILE` env var, the
/// `<vault_root>/vault.relkey` convention, or an interactive prompt.
pub fn get_keyfile_path() -> Result<PathBuf> {
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<Zeroizing<[u8; 32]>> {
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 <path>.tmp, then rename over <path>. 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(&params);
let pf = ParamsFile::for_new_vault(&params, 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();

View File

@@ -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();
}

View File

@@ -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::<SecondFactor>("\"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].

View File

@@ -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
//! <base64-standard of 32 bytes>
//! ```
//!
//! 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<base64>\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<u8> {
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<Zeroizing<[u8; 32]>> {
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<Vec<u8>> = 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());
}
}

View File

@@ -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};

View File

@@ -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<SessionHandle, JsError> {
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, &params)
.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<Vec<u8>, 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<Vec<u8>, 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<u8> {
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();

View File

@@ -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<void>;
export function initSync(args: { module: WebAssembly.Module }): void;
}