merge: origin/main (Dev-E keyfile-ext + D-minors) into org-foundation

A-vs-E keep-both integration in 3 security-sensitive SW files:
- shared/messages.ts: kept E's attach_vault keyfile second-factor fields
  (secondFactor/referenceImageBytes?/keyfileBytes?) AND my 4 org message types.
- vault.ts: registerDeviceAndPersistConfig auto-merged to take BOTH E's
  factorStorage (image|keyfile) AND my handle (Task 4.5 device-key persist);
  both create/attach call sites pass (factorStorage, deviceName, h) so each
  flow does E secret-gen AND my register+persist in one coherent path.
- router/popup-only.ts: took E's 'unlock' -> handleUnlock refactor and moved my
  device-key restore + migration into handleUnlock's shared tail (after
  setCurrent, before manifest fetch) so it runs for both image + keyfile unlock.
- keyfile-unlock.test.ts: makeWasm mock gains persist_device_key + restore_device_key
  stubs (the merged create/attach/unlock flow now calls them) — no assertion weakened.

Gates: SW vitest 175/175 (18 files); cargo core+wasm 243/0; wasm32 build clean;
clippy -D warnings clean; build:all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-27 11:22:17 -04:00
19 changed files with 1119 additions and 186 deletions

View File

@@ -56,7 +56,8 @@ pub fn cmd_init(image: Option<PathBuf>, output: PathBuf, key_file: Option<PathBu
// 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))
let armored = relicario_core::keyfile::keyfile_encode(&secret);
fs::write(&kf_path, armored.as_slice())
.with_context(|| format!("failed to write key file {}", kf_path.display()))?;
let name = kf
.file_name()

View File

@@ -1,4 +1,5 @@
use assert_cmd::prelude::*;
use predicates::prelude::PredicateBooleanExt;
use std::process::Command;
fn relicario(dir: &tempfile::TempDir) -> Command {
@@ -139,6 +140,86 @@ fn recovery_qr_generate_works_for_keyfile_vault() {
.stdout(predicates::str::contains("Recovery QR generated"));
}
/// `backup export --include-image` on a key-file vault must fail with a clear message.
/// (Key-file vaults have no reference JPEG to bundle.)
#[test]
fn backup_include_image_fails_on_keyfile_vault() {
let dir = tempfile::tempdir().unwrap();
relicario(&dir)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
let out_path = dir.path().join("vault.relbak");
relicario(&dir)
.args(["backup", "export", out_path.to_str().unwrap(), "--include-image"])
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", "strong-backup-pass-test-2026")
.assert()
.failure()
.stderr(predicates::str::contains("not applicable to a key-file vault"));
}
/// A malformed key file (garbage bytes) must fail with "invalid key file" in stderr.
/// This is the `keyfile_decode` → `.context("invalid key file")` path.
#[test]
fn malformed_keyfile_emits_invalid_key_file() {
let dir = tempfile::tempdir().unwrap();
relicario(&dir)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// Overwrite the key file with garbage — keyfile_decode will reject the header.
std::fs::write(dir.path().join("vault.relkey"), b"not a valid keyfile\n").unwrap();
relicario(&dir)
.args(["list"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
.assert()
.failure()
.stderr(predicates::str::contains("invalid key file"));
}
/// A well-formed but WRONG key file (valid armor, wrong 32-byte secret) must produce
/// a decryption failure — NOT "invalid key file". There must be no oracle about
/// which factor was wrong.
#[test]
fn wrong_secret_emits_decryption_failed_not_invalid_key_file() {
// Vault A: the target vault.
let dir_a = tempfile::tempdir().unwrap();
relicario(&dir_a)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// Add an item so there is encrypted manifest content to exercise.
relicario(&dir_a)
.args(["add", "login", "--title", "test", "--username", "u", "--password", "p"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir_a.path().join("vault.relkey"))
.assert()
.success();
// Vault B: a completely separate vault with a different random 32-byte secret.
let dir_b = tempfile::tempdir().unwrap();
relicario(&dir_b)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// Open vault A with vault B's key file: armor parses fine, but the derived
// master key is wrong → manifest decryption fails with RelicarioError::Decrypt.
relicario(&dir_a)
.args(["list"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir_b.path().join("vault.relkey"))
.assert()
.failure()
.stderr(predicates::str::contains("decryption failed"))
.stderr(predicates::str::contains("invalid key file").not());
}
/// --image and --key-file are mutually exclusive (clap rejects before the handler).
#[test]
fn init_with_both_factors_fails() {

View File

@@ -25,8 +25,8 @@ const HEADER: &str = "relicario-keyfile-v1";
///
/// 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()
pub fn keyfile_encode(secret: &[u8; 32]) -> Zeroizing<Vec<u8>> {
Zeroizing::new(format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes())
}
/// Decode a `relicario-keyfile-v1` armored file back to the raw 32-byte secret.

View File

@@ -101,7 +101,7 @@ pub fn unlock_with_secret(
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))
Ok(relicario_core::keyfile::keyfile_encode(arr).to_vec())
}
/// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret.