merge: feature/v0.9.0-dev-d-keyfile-core (v0.9.0 Dev-D keyfile core/cli/wasm)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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, ¶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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
¶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<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(¶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();
|
||||
|
||||
151
crates/relicario-cli/tests/keyfile_flows.rs
Normal file
151
crates/relicario-cli/tests/keyfile_flows.rs
Normal 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();
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
87
crates/relicario-core/src/keyfile.rs
Normal file
87
crates/relicario-core/src/keyfile.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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, ¶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<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();
|
||||
|
||||
5
extension/src/wasm.d.ts
vendored
5
extension/src/wasm.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user