# Plan 4: Security Blocker Fixes > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Clear all 4 audit blockers (B1-B4) + important items (I1-I6) + HOTP bug before v0.3.0 **Architecture:** Fix security-theater device keys by removal (not implementation), add missing validations/normalizations, and harden path handling. HOTP is disabled with a clear error since counter persistence requires vault save machinery that doesn't exist. **Tech Stack:** Rust (relicario-core, relicario-cli, relicario-wasm), TypeScript (extension) --- ## File Structure ### Core changes - `crates/relicario-core/src/backup.rs` — add NFC normalization (B2) - `crates/relicario-core/src/ids.rs` — expand AttachmentId to 128 bits (I2) - `crates/relicario-core/src/item_types/totp.rs` — reject HOTP with error (I6) - `crates/relicario-core/src/error.rs` — add HotpNotSupported variant ### CLI changes - `crates/relicario-cli/src/main.rs`: - Remove Device subcommand/enum/handler (B1) - Remove devices.json from init (B1) - Gate test env vars with #[cfg(test)] (B3) - Validate IDs on restore (B4) - Sanitize titles in commit messages (I1) - Enforce per-vault attachment cap (I3) - `crates/relicario-cli/src/helpers.rs` — add `sanitize_for_commit()` (I1) - `crates/relicario-cli/tests/basic_flows.rs` — remove devices.json assertion (B1) ### WASM changes - `crates/relicario-wasm/src/lib.rs` — remove `generate_device_keypair` (B1/I5) ### Extension changes (B1) - Remove `extension/src/popup/components/devices.ts` - Remove `extension/src/popup/components/__tests__/devices.test.ts` - Remove `extension/src/service-worker/devices.ts` - Remove `extension/src/service-worker/__tests__/devices.test.ts` - Update `extension/src/shared/messages.ts` — remove device message types - Update `extension/src/shared/types.ts` — remove Device type - Update `extension/src/popup/popup.ts` — remove devices route - Update `extension/src/popup/components/settings.ts` — remove devices link - Update `extension/src/service-worker/router/popup-only.ts` — remove device handlers - Update `extension/src/wasm.d.ts` — remove generate_device_keypair ### Documentation (I4) - `docs/SECURITY.md` — document manifest integrity model --- ## Task 1: Remove device key system from CLI (B1) **Files:** - Modify: `crates/relicario-cli/src/main.rs:165-168, 315-320, 388, 500, 518, 2151-2221` - Modify: `crates/relicario-cli/tests/basic_flows.rs:11` - [ ] **Step 1: Write test that init does NOT create devices.json** ```rust // In crates/relicario-cli/tests/basic_flows.rs, modify init_creates_expected_layout: #[test] fn init_creates_expected_layout() { let v = TestVault::init(); assert!(v.path().join(".relicario/salt").exists()); assert!(v.path().join(".relicario/params.json").exists()); // devices.json removed — device key system was security theater assert!(!v.path().join(".relicario/devices.json").exists()); assert!(v.path().join("manifest.enc").exists()); assert!(v.path().join("settings.enc").exists()); assert!(v.path().join("reference.jpg").exists()); assert!(v.path().join(".gitignore").exists()); assert!(v.path().join(".git").is_dir()); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p relicario-cli --test basic_flows init_creates_expected_layout` Expected: FAIL — devices.json still exists - [ ] **Step 3: Remove DeviceAction enum** In `main.rs`, delete lines 315-320: ```rust // DELETE THIS BLOCK: #[derive(Subcommand)] enum DeviceAction { Add { #[arg(long)] name: String }, List, Revoke { name: String }, } ``` - [ ] **Step 4: Remove Device variant from Commands enum** In `main.rs`, delete lines 164-168: ```rust // DELETE THIS BLOCK: /// Device management. Device { #[command(subcommand)] action: DeviceAction, }, ``` - [ ] **Step 5: Remove Device match arm** In `main.rs`, delete line 388: ```rust // DELETE THIS LINE: Commands::Device { action } => cmd_device(action), ``` - [ ] **Step 6: Remove devices.json creation in cmd_init** In `main.rs`, delete line 500: ```rust // DELETE THIS LINE: fs::write(relicario_dir.join("devices.json"), b"[]")?; ``` - [ ] **Step 7: Remove devices.json from git add in cmd_init** In `main.rs` line 517-519, change: ```rust // FROM: let _ = crate::helpers::git_command(&root, &[ "add", ".gitignore", ".relicario/params.json", ".relicario/devices.json", ".relicario/salt", "manifest.enc", "settings.enc", ]).status()?; // TO: let _ = crate::helpers::git_command(&root, &[ "add", ".gitignore", ".relicario/params.json", ".relicario/salt", "manifest.enc", "settings.enc", ]).status()?; ``` - [ ] **Step 8: Remove cmd_device function** Delete the entire `cmd_device` function (lines 2151-2221). - [ ] **Step 9: Run test to verify it passes** Run: `cargo test -p relicario-cli --test basic_flows init_creates_expected_layout` Expected: PASS - [ ] **Step 10: Run full CLI test suite** Run: `cargo test -p relicario-cli` Expected: All tests pass (some may need adjustment if they reference devices) - [ ] **Step 11: Commit** ```bash git add crates/relicario-cli/ git commit -m "$(cat <<'EOF' fix(cli): remove device key system (audit B1) The device add/list/revoke commands generated and stored ed25519 keys but no code ever signed commits or verified signatures. This gave users a false sense of security around device revocation. Removed: Device subcommand, DeviceAction enum, cmd_device handler, devices.json creation. Access control remains SSH-key-level only. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 2: Remove device key system from WASM (B1/I5) **Files:** - Modify: `crates/relicario-wasm/src/lib.rs:212-227` - [ ] **Step 1: Remove generate_device_keypair function** Delete lines 212-227 in `lib.rs`: ```rust // DELETE THIS BLOCK: /// Generate an ed25519 keypair for device registration. /// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." } #[wasm_bindgen] pub fn generate_device_keypair() -> Result { let mut rng = rand::thread_rng(); let signing_key = SigningKey::generate(&mut rng); let verifying_key = signing_key.verifying_key(); let public_hex = hex::encode(verifying_key.as_bytes()); let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes()); js_value_for(&serde_json::json!({ "public_key_hex": public_hex, "private_key_base64": private_b64, })) } ``` - [ ] **Step 2: Remove ed25519_dalek import if no longer needed** Check if `SigningKey` is used elsewhere. If not, remove the import. - [ ] **Step 3: Build WASM to verify compilation** Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` Expected: Build succeeds - [ ] **Step 4: Commit** ```bash git add crates/relicario-wasm/ git commit -m "$(cat <<'EOF' fix(wasm): remove generate_device_keypair (audit B1/I5) Private key was being returned to JS heap with no Zeroizing protection. The entire device key system is removed as security theater. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 3: Remove device UI from extension (B1) **Files:** - Delete: `extension/src/popup/components/devices.ts` - Delete: `extension/src/popup/components/__tests__/devices.test.ts` - Delete: `extension/src/service-worker/devices.ts` - Delete: `extension/src/service-worker/__tests__/devices.test.ts` - Modify: `extension/src/shared/messages.ts` - Modify: `extension/src/shared/types.ts` - Modify: `extension/src/popup/popup.ts` - Modify: `extension/src/popup/components/settings.ts` - Modify: `extension/src/service-worker/router/popup-only.ts` - Modify: `extension/src/wasm.d.ts` - [ ] **Step 1: Delete device component files** ```bash rm extension/src/popup/components/devices.ts rm extension/src/popup/components/__tests__/devices.test.ts rm extension/src/service-worker/devices.ts rm extension/src/service-worker/__tests__/devices.test.ts ``` - [ ] **Step 2: Remove Device type from types.ts** Find and remove the `Device` interface in `extension/src/shared/types.ts`. - [ ] **Step 3: Remove device message types from messages.ts** Find and remove `list_devices`, `register_device`, `revoke_device` message types. - [ ] **Step 4: Remove devices route from popup.ts** Remove the devices case from the route switch and the import. - [ ] **Step 5: Remove devices link from settings.ts** Find and remove the "Devices" button/link in the settings view. - [ ] **Step 6: Remove device handlers from popup-only.ts** Remove the `list_devices`, `register_device`, `revoke_device` handlers. - [ ] **Step 7: Remove generate_device_keypair from wasm.d.ts** Delete line 64 in `extension/src/wasm.d.ts`: ```typescript // DELETE THIS LINE: export function generate_device_keypair(): { public_key_hex: string; private_key_base64: string }; ``` - [ ] **Step 8: Run TypeScript check** Run: `cd extension && npm run typecheck` Expected: No errors - [ ] **Step 9: Run extension tests** Run: `cd extension && npm test` Expected: All tests pass (device tests are deleted) - [ ] **Step 10: Commit** ```bash git add extension/ git commit -m "$(cat <<'EOF' fix(extension): remove device UI (audit B1) Device registration/revocation provided no real security since the underlying signing infrastructure was never implemented. Removed all device-related components, message handlers, and type definitions. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 4: Fix backup KDF NFC normalization (B2) **Files:** - Modify: `crates/relicario-core/src/backup.rs:303-312` - Create test in: `crates/relicario-core/tests/backup.rs` (or add to existing) - [ ] **Step 1: Write failing test for NFC normalization** ```rust // In crates/relicario-core/tests/backup.rs or a new test file: #[test] fn backup_kdf_normalizes_nfc() { use relicario_core::backup::{pack, unpack}; // "café" in NFD (decomposed: e + combining acute) vs NFC (composed: é) let nfd_passphrase = "caf\u{0065}\u{0301}"; // e + combining accent let nfc_passphrase = "caf\u{00E9}"; // precomposed é // Create a minimal backup with NFD passphrase let input = BackupInput { salt: &[0u8; 32], params_json: b"{}", devices_json: b"[]", manifest_enc: &[1, 2, 3], settings_enc: &[4, 5, 6], items: vec![], attachments: vec![], }; let packed = pack(input, nfd_passphrase, false, false).unwrap(); // Unpack with NFC passphrase — should work because both normalize to same let unpacked = unpack(&packed, nfc_passphrase).unwrap(); assert_eq!(unpacked.manifest_enc, vec![1, 2, 3]); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p relicario-core backup_kdf_normalizes_nfc` Expected: FAIL — NFD and NFC produce different keys - [ ] **Step 3: Add NFC normalization to derive_backup_key** In `backup.rs`, modify `derive_backup_key`: ```rust fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result> { use unicode_normalization::UnicodeNormalization; // NFC normalize passphrase (same as derive_master_key in crypto.rs) let nfc_passphrase: Vec = match std::str::from_utf8(passphrase) { Ok(s) => s.nfc().collect::().into_bytes(), Err(_) => passphrase.to_vec(), }; let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32)) .map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?; let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut key = Zeroizing::new([0u8; 32]); argon .hash_password_into(&nfc_passphrase, salt, key.as_mut_slice()) .map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?; Ok(key) } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cargo test -p relicario-core backup_kdf_normalizes_nfc` Expected: PASS - [ ] **Step 5: Commit** ```bash git add crates/relicario-core/ git commit -m "$(cat <<'EOF' fix(core): NFC normalize backup passphrase (audit B2) Backup KDF was passing raw passphrase bytes to Argon2id without NFC normalization, causing cross-platform restore failures for non-ASCII passphrases (macOS NFD vs Linux NFC). Now matches derive_master_key behavior from crypto.rs. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 5: Gate test env vars with #[cfg(test)] (B3) **Files:** - Modify: `crates/relicario-cli/src/main.rs:417-450, 1425-1430, 1594` - [ ] **Step 1: Identify all test env var locations** Locations found: - Lines 417-423: `RELICARIO_TEST_ITEM_SECRET` - Lines 443-450: `RELICARIO_TEST_PASSPHRASE` - Lines 1425-1430: `RELICARIO_TEST_BACKUP_PASSPHRASE` - Line 1594: `RELICARIO_TEST_BACKUP_PASSPHRASE` - [ ] **Step 2: Wrap prompt_item_secret test bypass** ```rust // Lines 417-423, change: fn prompt_item_secret(prompt: &str) -> Result> { #[cfg(test)] if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") { return Ok(Zeroizing::new(s)); } let pass = rpassword::prompt_password(prompt) .context("failed to read secret")?; Ok(Zeroizing::new(pass)) } ``` - [ ] **Step 3: Wrap prompt_passphrase test bypass** ```rust // Lines 443-450, change: fn prompt_passphrase(prompt: &str, confirm: bool) -> Result> { #[cfg(test)] let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") { Zeroizing::new(p) } else { Zeroizing::new(rpassword::prompt_password(prompt).context("failed to read passphrase")?) }; #[cfg(not(test))] let passphrase = Zeroizing::new(rpassword::prompt_password(prompt).context("failed to read passphrase")?); #[cfg(test)] let confirm_skip = std::env::var_os("RELICARIO_TEST_PASSPHRASE").is_some(); #[cfg(not(test))] let confirm_skip = false; if confirm && !confirm_skip { // ... existing confirm logic } Ok(passphrase) } ``` - [ ] **Step 4: Wrap backup passphrase test bypass (export)** Apply same pattern to lines 1425-1430. - [ ] **Step 5: Wrap backup passphrase test bypass (restore)** Apply same pattern to line 1594. - [ ] **Step 6: Build release binary and verify env vars are not present** Run: `cargo build -p relicario-cli --release && strings target/release/relicario | grep RELICARIO_TEST` Expected: No output (strings not present in release binary) - [ ] **Step 7: Run tests to verify they still work** Run: `cargo test -p relicario-cli` Expected: All tests pass - [ ] **Step 8: Commit** ```bash git add crates/relicario-cli/ git commit -m "$(cat <<'EOF' fix(cli): gate test env vars with #[cfg(test)] (audit B3) RELICARIO_TEST_PASSPHRASE and friends were checked in production code, exposing the passphrase via /proc//environ and shell history. Now only compiled into test binaries. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 6: Validate IDs on backup restore (B4) **Files:** - Modify: `crates/relicario-cli/src/main.rs:1619-1626` - Modify: `crates/relicario-core/src/ids.rs` (add validation function) - [ ] **Step 1: Write failing test for path traversal** ```rust // In crates/relicario-cli/tests/attachments.rs or backup tests: #[test] fn restore_rejects_traversal_ids() { // Create a backup with malicious item ID // Attempt restore // Expect error, not file write outside target } ``` - [ ] **Step 2: Add is_valid_id function to ids.rs** ```rust impl ItemId { /// Returns true if this ID is safe for use in filesystem paths. /// Valid IDs are 16 hex characters (8 bytes). pub fn is_valid(&self) -> bool { self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit()) } } impl AttachmentId { /// Returns true if this ID is safe for use in filesystem paths. /// Valid IDs are 32 hex characters (16 bytes) after I2 fix. pub fn is_valid(&self) -> bool { self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit()) } } ``` - [ ] **Step 3: Validate IDs during restore** In `main.rs` around line 1619, add validation: ```rust for item in &unpacked.items { if !item.id.is_valid() { anyhow::bail!("invalid item ID in backup: {}", item.id.as_str()); } fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; } for a in &unpacked.attachments { if !a.item_id.is_valid() || !a.attachment_id.is_valid() { anyhow::bail!("invalid attachment ID in backup"); } let dir = target.join("attachments").join(&a.item_id); fs::create_dir_all(&dir)?; fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cargo test -p relicario-cli restore_rejects_traversal` Expected: PASS - [ ] **Step 5: Commit** ```bash git add crates/relicario-core/src/ids.rs crates/relicario-cli/ git commit -m "$(cat <<'EOF' fix(cli): validate IDs on backup restore (audit B4) Crafted .relbak files with IDs like "../../.bashrc" could escape the target directory. Now validates that item/attachment IDs are hex-only before any fs::write. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 7: Sanitize item titles in commit messages (I1) **Files:** - Modify: `crates/relicario-cli/src/helpers.rs` - Modify: `crates/relicario-cli/src/main.rs:565, 1110, 1327` - [ ] **Step 1: Add sanitize_for_commit to helpers.rs** ```rust /// Sanitize a string for use in git commit messages. /// Removes control characters and truncates to 50 chars. pub fn sanitize_for_commit(s: &str) -> String { s.chars() .filter(|c| !c.is_control()) .take(50) .collect() } ``` - [ ] **Step 2: Use sanitize_for_commit in add commit** ```rust // Line 565, change: commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?; ``` - [ ] **Step 3: Use sanitize_for_commit in edit commit** ```rust // Line 1110, change: commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), ``` - [ ] **Step 4: Use sanitize_for_commit in trash commit** ```rust // Line 1327, change: commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), ``` - [ ] **Step 5: Write test for sanitization** ```rust #[test] fn sanitize_for_commit_strips_newlines() { assert_eq!(sanitize_for_commit("line1\nline2"), "line1line2"); assert_eq!(sanitize_for_commit("a\tb"), "ab"); assert_eq!(sanitize_for_commit("normal"), "normal"); } ``` - [ ] **Step 6: Run tests** Run: `cargo test -p relicario-cli` Expected: PASS - [ ] **Step 7: Commit** ```bash git add crates/relicario-cli/ git commit -m "$(cat <<'EOF' fix(cli): sanitize item titles in commit messages (audit I1) Control characters (newlines, tabs) in item titles corrupted git log output. Now strips control chars and truncates to 50 chars. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 8: Expand AttachmentId to 128 bits (I2) **Files:** - Modify: `crates/relicario-core/src/ids.rs:54` - [ ] **Step 1: Write test for 128-bit AttachmentId** ```rust #[test] fn attachment_id_is_128_bits() { let id = AttachmentId::from_plaintext(b"test content"); assert_eq!(id.as_str().len(), 32); // 16 bytes = 32 hex chars } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p relicario-core attachment_id_is_128_bits` Expected: FAIL — currently 16 hex chars (64 bits) - [ ] **Step 3: Change digest slice from 8 to 16 bytes** ```rust // Line 54, change: // FROM: Self(hex::encode(&digest[..8])) // TO: Self(hex::encode(&digest[..16])) ``` - [ ] **Step 4: Run test to verify it passes** Run: `cargo test -p relicario-core attachment_id_is_128_bits` Expected: PASS - [ ] **Step 5: Update is_valid() in Task 6 if already implemented** Ensure `AttachmentId::is_valid()` checks for 32 chars, not 16. - [ ] **Step 6: Run full test suite** Run: `cargo test` Expected: All tests pass - [ ] **Step 7: Commit** ```bash git add crates/relicario-core/ git commit -m "$(cat <<'EOF' fix(core): expand AttachmentId to 128 bits (audit I2) 64-bit truncation allowed birthday collisions at ~2^32 work. Now uses 16 bytes (128 bits) of SHA-256, requiring ~2^64 work for collision. No migration needed — only affects new attachments. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 9: Enforce per-vault attachment cap (I3) **Files:** - Modify: `crates/relicario-cli/src/main.rs` — cmd_attach function - [ ] **Step 1: Find cmd_attach and calculate vault total** In `cmd_attach`, before calling `encrypt_attachment`, sum existing attachment sizes from manifest. - [ ] **Step 2: Implement vault-level cap check** ```rust // In cmd_attach, after loading manifest and settings: let current_total: u64 = manifest.items.values() .flat_map(|entry| &entry.attachments) .map(|a| a.size) .sum(); let new_size = bytes.len() as u64; let hard_cap = settings.attachment_caps.per_vault_hard_cap_bytes; if current_total + new_size > hard_cap { anyhow::bail!( "attachment would exceed vault cap ({} + {} > {} bytes)", current_total, new_size, hard_cap ); } let soft_cap = settings.attachment_caps.per_vault_soft_cap_bytes; if current_total + new_size > soft_cap { eprintln!("warning: vault attachment total exceeds soft cap ({} bytes)", soft_cap); } ``` - [ ] **Step 3: Write test for cap enforcement** ```rust #[test] fn attach_enforces_vault_cap() { // Create vault with low hard cap in settings // Add attachments until cap is hit // Verify next attachment fails } ``` - [ ] **Step 4: Run tests** Run: `cargo test -p relicario-cli` Expected: PASS - [ ] **Step 5: Commit** ```bash git add crates/relicario-cli/ git commit -m "$(cat <<'EOF' fix(cli): enforce per-vault attachment bytes cap (audit I3) per_vault_soft_cap_bytes and per_vault_hard_cap_bytes were defined in VaultSettings but never checked. Now enforced in cmd_attach with warning at soft cap, error at hard cap. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 10: Disable HOTP with clear error (I6) **Files:** - Modify: `crates/relicario-core/src/error.rs` - Modify: `crates/relicario-core/src/item_types/totp.rs:71-76` - [ ] **Step 1: Add HotpNotSupported error variant** ```rust // In error.rs: #[derive(Debug, Error)] pub enum RelicarioError { // ... existing variants ... #[error("HOTP is not supported: counter persistence requires vault save")] HotpNotSupported, } ``` - [ ] **Step 2: Reject HOTP in compute_totp_code** ```rust // In totp.rs, compute_totp_code: pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result { let counter = match config.kind { TotpKind::Totp => now_unix_seconds / config.period_seconds as u64, TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported), TotpKind::Steam => now_unix_seconds / config.period_seconds as u64, }; // ... rest unchanged } ``` - [ ] **Step 3: Update HOTP test to expect error** ```rust #[test] fn hotp_returns_not_supported_error() { let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() }; let result = compute_totp_code(&cfg, 0); assert!(matches!(result, Err(RelicarioError::HotpNotSupported))); } ``` - [ ] **Step 4: Run tests** Run: `cargo test -p relicario-core` Expected: PASS (after updating any tests that expected HOTP to work) - [ ] **Step 5: Commit** ```bash git add crates/relicario-core/ git commit -m "$(cat <<'EOF' fix(core): disable HOTP with clear error (audit I6) HOTP requires incrementing and persisting the counter after each use. Without vault-save machinery in compute_totp_code, HOTP would desync immediately. Now returns HotpNotSupported error. TOTP and Steam codes continue to work. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 11: Document manifest integrity model (I4) **Files:** - Create: `docs/SECURITY.md` - [ ] **Step 1: Write SECURITY.md** ```markdown # Relicario Security Model ## Manifest Integrity The manifest (`manifest.enc`) is encrypted with AEAD (XChaCha20-Poly1305), which provides: - **Confidentiality**: Contents are unreadable without the master key - **Integrity**: Any modification is detected and rejected on decrypt - **Authenticity**: Only holders of the master key can create valid ciphertexts ### What AEAD Does NOT Protect - **Item deletion**: An attacker with write access to the vault can delete `.enc` files or git-revert commits. The manifest will decrypt successfully but won't contain the deleted items. - **Rollback attacks**: An attacker can replace `manifest.enc` with an older valid version. AEAD accepts any ciphertext created with the correct key. ### Mitigation Item deletion and rollback are detectable via **git history**. The git log provides an append-only audit trail: ```bash git log --oneline items/ ``` For environments where git history could be rewritten (e.g., force-push), consider: 1. A pre-receive hook that rejects non-fast-forward pushes 2. Signed commits (not currently implemented) 3. Regular backups with `relicario backup export` ## Access Control Relicario does not implement device-level access control. All access control is at the transport layer: - **CLI**: SSH key authentication to the git remote - **Extension**: Git credentials stored in browser profile Device registration/revocation was removed in v0.3.0 as it provided no actual security benefit without commit signing infrastructure. ``` - [ ] **Step 2: Commit** ```bash git add docs/SECURITY.md git commit -m "$(cat <<'EOF' docs: document manifest integrity model (audit I4) Clarifies what AEAD protects (tampering) vs. what it doesn't (deletion, rollback). Documents that git history is the audit trail. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 12: Update backup to not include devices.json (B1 cleanup) **Files:** - Modify: `crates/relicario-core/src/backup.rs:61, 138` - Modify: `crates/relicario-cli/src/main.rs:1447-1448, 1615` - [ ] **Step 1: Remove devices_json from BackupInput struct** In `backup.rs`, remove the `devices_json` field from `BackupInput` and `UnpackedBackup`. - [ ] **Step 2: Update pack/unpack to not include devices** Remove all references to `devices_json` in the pack/unpack logic. - [ ] **Step 3: Update CLI export to not read devices.json** Remove lines 1447-1448 that read devices.json. - [ ] **Step 4: Update CLI restore to not write devices.json** Remove line 1615 that writes devices.json. - [ ] **Step 5: Run backup tests** Run: `cargo test -p relicario-cli --test attachments` Run: `cargo test -p relicario-core` Expected: PASS - [ ] **Step 6: Commit** ```bash git add crates/relicario-core/ crates/relicario-cli/ git commit -m "$(cat <<'EOF' fix(backup): remove devices.json from backup format (B1 cleanup) Part of device key system removal. Backup files no longer include devices.json since the device registration system is removed. Co-Authored-By: Claude Opus 4.5 EOF )" ``` --- ## Task 13: Final verification - [ ] **Step 1: Run full test suite** Run: `cargo test` Expected: All tests pass - [ ] **Step 2: Build all targets** ```bash cargo build cargo build -p relicario-wasm --target wasm32-unknown-unknown cd extension && npm run build ``` Expected: All builds succeed - [ ] **Step 3: Manual smoke test** ```bash # Create new vault relicario init test.jpg --output ref.jpg # Verify no devices.json ls .relicario/ # Should show: params.json, salt (no devices.json) # Add item with special chars in title relicario add login --title $'Test\nNewline' --username test # Check git log for sanitized commit message git log -1 --oneline # Should show: add: TestNewline (...) ``` - [ ] **Step 4: Commit verification summary** ```bash git log --oneline -15 ``` Verify all 12+ commits are present with appropriate messages. --- ## Completion Checklist - [ ] B1: Device key system removed (CLI, WASM, extension, backup) - [ ] B2: Backup KDF NFC normalization - [ ] B3: Test env vars gated with #[cfg(test)] - [ ] B4: Path traversal validation on restore - [ ] I1: Item titles sanitized in commits - [ ] I2: AttachmentId expanded to 128 bits - [ ] I3: Per-vault attachment cap enforced - [ ] I4: SECURITY.md documents manifest integrity - [ ] I5: (Covered by B1 — generate_device_keypair removed) - [ ] I6: HOTP returns HotpNotSupported error - [ ] All tests pass - [ ] All builds succeed