diff --git a/docs/superpowers/plans/2026-04-18-idfoto-typed-items-1a-rust-core.md b/docs/superpowers/plans/2026-04-18-idfoto-typed-items-1a-rust-core.md new file mode 100644 index 0000000..cee832b --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-idfoto-typed-items-1a-rust-core.md @@ -0,0 +1,4076 @@ +# Typed Items — Plan 1A: Rust Core Implementation Plan + +> **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:** Replace `idfoto-core`'s single-`Entry` data model with the polymorphic typed-item system (Login, SecureNote, Identity, Card, Key, Document, TOTP) defined in `docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md`. Bake in the audit's crypto fixes (length-prefixed Argon2 input, Zeroize discipline, opaque Decrypt error, 16-char hex IDs, CSPRNG-only generators) and add support for sections, custom fields, attachments, password history, soft-delete, and vault settings. + +**Architecture:** Pure Rust library; no filesystem, no network, no git. Bytes-in/bytes-out. Each typed core lives in its own `src/item_types/.rs` module. The `ItemCore` enum + `match` exhaustiveness is the extension mechanism. Sensitive plaintext fields wrap `Zeroizing<>` at the struct level. Format version 2 — clean break from v1 (no migration). All sensitive fields serialize through `Zeroizing` so JSON-then-AEAD pipeline never leaves plaintext on the heap longer than necessary. + +**Tech Stack:** Rust 2021, `serde` + `serde_json` (existing), `argon2` 0.5 (existing), `chacha20poly1305` 0.10 (existing), `rand` 0.8 (existing), plus new deps: `zeroize` 1, `zeroize_derive` 1, `zxcvbn` 3, `bip39` 2, `unicode-normalization` 0.1, `chrono` 0.4 (with `serde`), `hex` 0.4, `url` 2, `getrandom` 0.2 (with `js` feature defaulting off — wasm crate enables it). + +**Spec:** `docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md` +**Audit:** `docs/superpowers/audits/2026-04-18-initial-security-audit.md` + +**Out of scope for this plan (covered by Plans 1B and 1C):** +- WASM session-handle bridge (Plan 1B) +- CLI rewrite (Plan 1B) +- Browser extension changes (Plan 1C) +- imgsecret changes (audit M3 lives in Plan 1B's CLI work; the extension MAX_DIMENSION cap lives in Plan 1C) + +--- + +## File Structure + +``` +crates/idfoto-core/ +├── Cargo.toml # MODIFY: add new deps +└── src/ + ├── lib.rs # MODIFY: module declarations, re-exports + ├── error.rs # MODIFY: opaque Decrypt, new variants + ├── crypto.rs # MODIFY: length-prefix KDF, Zeroize, NFC, VERSION_BYTE 0x02 + ├── imgsecret.rs # UNTOUCHED in this plan + ├── entry.rs # DELETE (final step) + ├── vault.rs # MODIFY: replace Entry helpers with Item helpers + ├── ids.rs # NEW: ItemId/FieldId/AttachmentId types + generators + ├── time.rs # NEW: unix-seconds helpers + MonthYear + ├── item.rs # NEW: Item, Section, Field, FieldKind, FieldValue, FieldHistoryEntry + ├── attachment.rs # NEW: AttachmentRef, encrypt_attachment, decrypt_attachment + ├── manifest.rs # REWRITE: typed-item Manifest + ManifestEntry + AttachmentSummary + ├── settings.rs # NEW: VaultSettings + retention/generator/cap types + ├── generators.rs # NEW: generate_password, generate_passphrase, rate_passphrase + └── item_types/ + ├── mod.rs # NEW: ItemType + ItemCore enum + dispatch + ├── login.rs # NEW: LoginCore + ├── secure_note.rs # NEW: SecureNoteCore + ├── identity.rs # NEW: IdentityCore + ├── card.rs # NEW: CardCore + CardKind + ├── key.rs # NEW: KeyCore + ├── document.rs # NEW: DocumentCore + └── totp.rs # NEW: TotpCore + TotpConfig + TotpAlgorithm + TotpKind +└── tests/ + ├── integration.rs # REWRITE: full workflow + two-factor independence (typed-item) + ├── field_history.rs # NEW: history-tracking integration tests + ├── attachments.rs # NEW: attachment round-trip + content-addressed AID + ├── generators.rs # NEW: bias + bip39 sanity + zxcvbn gate tests + └── format_v2.rs # NEW: VERSION_BYTE + length-prefix KDF tests + v1-rejection +``` + +**Conventions for tasks below:** +- Each task creates or modifies exactly the files listed under `**Files:**`. +- TDD: write the failing test first, run it to confirm it fails, write minimal code, run it to confirm it passes, commit. +- Commits use Conventional Commits (`feat:`, `test:`, `refactor:`, `chore:`). +- Co-author trailer on every commit: `Co-Authored-By: Claude Opus 4.7 (1M context) `. +- Run `cargo build -p idfoto-core` and `cargo test -p idfoto-core` between tasks to catch breakage early. Workspace will not build cleanly until Task 26 (when `idfoto-cli` is updated to use the new types) — this plan deliberately leaves `idfoto-cli` broken intermediate-state. Plan 1B fixes it. +- During this plan, `cargo check -p idfoto-cli` is expected to fail. Use `cargo test -p idfoto-core` (which doesn't build the CLI) to verify each task. + +--- + +## Task 0: Add new dependencies to Cargo.toml + +**Files:** +- Modify: `crates/idfoto-core/Cargo.toml` + +- [ ] **Step 1: Update `[dependencies]` section** + +Replace the current `[dependencies]` section with: + +```toml +[dependencies] +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +argon2 = "0.5" +chacha20poly1305 = "0.10" +rand = "0.8" +sha2 = "0.10" +ed25519-dalek = { version = "2", features = ["rand_core"] } +image = { version = "0.25", default-features = false, features = ["jpeg"] } + +# Typed-item additions +zeroize = { version = "1", features = ["zeroize_derive"] } +zxcvbn = { version = "3", default-features = false } +bip39 = { version = "2", default-features = false, features = ["std"] } +unicode-normalization = "0.1" +chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +hex = "0.4" +url = { version = "2", features = ["serde"] } +getrandom = "0.2" + +[dev-dependencies] +hex = "0.4" +``` + +- [ ] **Step 2: Verify deps resolve** + +Run: `cargo check -p idfoto-core --no-default-features 2>&1 | head -40` +Expected: no resolver errors; the existing `entry.rs` may report "unused import" warnings but should compile. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/Cargo.toml +git commit -m "$(cat <<'EOF' +chore(core): add deps for typed-item rewrite + +zeroize, zxcvbn, bip39, unicode-normalization, chrono, hex, url, getrandom. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 1: ID types (ItemId, FieldId, AttachmentId) + +**Files:** +- Create: `crates/idfoto-core/src/ids.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write failing tests in `src/ids.rs`** + +Create `crates/idfoto-core/src/ids.rs`: + +```rust +//! Random and content-addressed identifiers for items, fields, and attachments. +//! +//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy) +//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format). +//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` — +//! content-addressed so identical plaintext blobs deduplicate naturally in git. + +use rand::rngs::OsRng; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ItemId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct FieldId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AttachmentId(pub String); + +impl ItemId { + pub fn new() -> Self { + let mut bytes = [0u8; 8]; + OsRng.fill_bytes(&mut bytes); + Self(hex::encode(bytes)) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +impl FieldId { + pub fn new() -> Self { + let mut bytes = [0u8; 8]; + OsRng.fill_bytes(&mut bytes); + Self(hex::encode(bytes)) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +impl AttachmentId { + pub fn from_plaintext(plaintext: &[u8]) -> Self { + let digest = Sha256::digest(plaintext); + Self(hex::encode(&digest[..8])) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_id_is_16_hex_chars() { + let id = ItemId::new(); + assert_eq!(id.0.len(), 16); + assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn item_ids_are_unique() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..10_000 { + assert!(seen.insert(ItemId::new().0)); + } + } + + #[test] + fn field_id_is_16_hex_chars() { + let id = FieldId::new(); + assert_eq!(id.0.len(), 16); + assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn attachment_id_is_deterministic() { + let plaintext = b"hello world"; + let a = AttachmentId::from_plaintext(plaintext); + let b = AttachmentId::from_plaintext(plaintext); + assert_eq!(a, b); + } + + #[test] + fn attachment_id_changes_with_plaintext() { + let a = AttachmentId::from_plaintext(b"hello"); + let b = AttachmentId::from_plaintext(b"world"); + assert_ne!(a, b); + } + + #[test] + fn attachment_id_is_16_hex_chars() { + let id = AttachmentId::from_plaintext(b"any bytes"); + assert_eq!(id.0.len(), 16); + assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn ids_serialize_as_bare_strings() { + let item = ItemId("abcdef0123456789".to_string()); + let json = serde_json::to_string(&item).unwrap(); + assert_eq!(json, "\"abcdef0123456789\""); + + let parsed: ItemId = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, item); + } +} +``` + +- [ ] **Step 2: Wire module into lib.rs** + +Add to `crates/idfoto-core/src/lib.rs` after the `pub mod error;` line: + +```rust +pub mod ids; +pub use ids::{AttachmentId, FieldId, ItemId}; +``` + +- [ ] **Step 3: Run tests, expect pass (these are unit tests in the new module)** + +Run: `cargo test -p idfoto-core --lib ids:: 2>&1 | tail -20` +Expected: 7 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/ids.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add ItemId, FieldId, AttachmentId types + +16-char hex (64-bit) random IDs for items and fields (audit M8). +AttachmentId is sha256(plaintext)[..16] for content-addressing. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Time helpers + MonthYear + +**Files:** +- Create: `crates/idfoto-core/src/time.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write `src/time.rs` with tests** + +```rust +//! Time helpers and the `MonthYear` type used for card expiries. + +use serde::{Deserialize, Serialize}; + +/// Current Unix timestamp in seconds. +pub fn now_unix() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Month + year (1-12 / e.g. 2026). Used for card expiries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct MonthYear { + pub month: u8, + pub year: u16, +} + +impl MonthYear { + pub fn new(month: u8, year: u16) -> Result { + if !(1..=12).contains(&month) { + return Err("month must be 1..=12"); + } + if year < 2000 || year > 2099 { + return Err("year must be 2000..=2099"); + } + Ok(Self { month, year }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn now_unix_is_positive_and_recent() { + let t = now_unix(); + assert!(t > 1_700_000_000); // after late 2023 + assert!(t < 4_000_000_000); // before 2096 + } + + #[test] + fn month_year_constructor_rejects_bad_month() { + assert!(MonthYear::new(0, 2026).is_err()); + assert!(MonthYear::new(13, 2026).is_err()); + assert!(MonthYear::new(1, 2026).is_ok()); + assert!(MonthYear::new(12, 2026).is_ok()); + } + + #[test] + fn month_year_constructor_rejects_bad_year() { + assert!(MonthYear::new(1, 1999).is_err()); + assert!(MonthYear::new(1, 2100).is_err()); + assert!(MonthYear::new(1, 2000).is_ok()); + assert!(MonthYear::new(1, 2099).is_ok()); + } + + #[test] + fn month_year_round_trips_through_json() { + let my = MonthYear::new(7, 2030).unwrap(); + let json = serde_json::to_string(&my).unwrap(); + let parsed: MonthYear = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, my); + } +} +``` + +- [ ] **Step 2: Wire into lib.rs** + +Add after the `pub mod ids;` line: + +```rust +pub mod time; +pub use time::{now_unix, MonthYear}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p idfoto-core --lib time:: 2>&1 | tail -20` +Expected: 4 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/time.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add now_unix() and MonthYear + +MonthYear used for card expiries; bounds 2000..=2099 are intentional. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Error type rewrite (opaque Decrypt + new variants) + +**Files:** +- Modify: `crates/idfoto-core/src/error.rs` + +- [ ] **Step 1: Write failing test** + +Append to `crates/idfoto-core/src/error.rs` (inside a new `#[cfg(test)] mod tests` block at the end): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decrypt_error_message_is_opaque() { + let err = IdfotoError::Decrypt; + assert_eq!(format!("{}", err), "decryption failed"); + } + + #[test] + fn weak_passphrase_carries_score() { + let err = IdfotoError::WeakPassphrase { score: 1 }; + let s = format!("{}", err); + assert!(s.contains("passphrase")); + assert!(s.contains("strength")); + } + + #[test] + fn attachment_too_large_reports_sizes() { + let err = IdfotoError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 }; + let s = format!("{}", err); + assert!(s.contains("11000000")); + assert!(s.contains("10485760")); + } + + #[test] + fn item_not_found_carries_id() { + let err = IdfotoError::ItemNotFound("abc123".to_string()); + assert!(format!("{}", err).contains("abc123")); + } + + #[test] + fn unsupported_format_version_reports_byte() { + let err = IdfotoError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }; + let s = format!("{}", err); + assert!(s.contains("01") || s.contains("1")); + assert!(s.contains("02") || s.contains("2")); + } +} +``` + +- [ ] **Step 2: Run tests, expect fail (variants don't exist yet)** + +Run: `cargo test -p idfoto-core --lib error:: 2>&1 | tail -30` +Expected: compile errors about missing variants `WeakPassphrase`, `AttachmentTooLarge`, `ItemNotFound`, `UnsupportedFormatVersion`, plus the existing `Decrypt` message check failing. + +- [ ] **Step 3: Update IdfotoError variants** + +Replace the `IdfotoError` enum in `crates/idfoto-core/src/error.rs` with: + +```rust +#[derive(Debug, Error)] +pub enum IdfotoError { + #[error("key derivation failed: {0}")] + Kdf(String), + + #[error("encryption failed: {0}")] + Encrypt(String), + + /// Authenticated decryption failed. Message intentionally opaque (audit M4). + #[error("decryption failed")] + Decrypt, + + #[error("invalid vault format: {0}")] + Format(String), + + #[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")] + UnsupportedFormatVersion { found: u8, expected: u8 }, + + /// An item was looked up by ID but does not exist in the manifest. + #[error("item not found: {0}")] + ItemNotFound(String), + + /// A passphrase failed the strength gate at vault creation (audit H3). + #[error("passphrase strength insufficient (score {score}/4)")] + WeakPassphrase { score: u8 }, + + /// An attachment exceeded the per-attachment cap from VaultSettings. + #[error("attachment too large: {size} bytes > {max} bytes max")] + AttachmentTooLarge { size: u64, max: u64 }, + + #[error("imgsecret: {0}")] + ImgSecret(String), + + #[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")] + ImageTooSmall { + min_width: u32, + min_height: u32, + actual_width: u32, + actual_height: u32, + }, + + #[error("extraction failed: no valid secret found in image")] + ExtractionFailed, + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("device key error: {0}")] + DeviceKey(String), +} +``` + +Keep the `pub type Result = std::result::Result;` line at the end. + +- [ ] **Step 4: Run tests, expect pass** + +Run: `cargo test -p idfoto-core --lib error:: 2>&1 | tail -30` +Expected: 5 tests passed. The `entry.rs` module may now have warnings about the removed `EntryNotFound` variant — ignore for now; Task 25 deletes `entry.rs`. + +If `entry.rs` won't compile due to `EntryNotFound`, search-and-replace `IdfotoError::EntryNotFound` → `IdfotoError::ItemNotFound` in `entry.rs`, `vault.rs`, and any other file in the crate that references it. Re-run the tests above. + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-core/src/error.rs crates/idfoto-core/src/entry.rs crates/idfoto-core/src/vault.rs +git commit -m "$(cat <<'EOF' +refactor(core): rewrite IdfotoError variants for typed items + +- Decrypt is now opaque (audit M4) +- Add WeakPassphrase, AttachmentTooLarge, ItemNotFound, UnsupportedFormatVersion +- Rename EntryNotFound → ItemNotFound across remaining call sites + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Crypto — length-prefixed Argon2 input + Zeroize + NFC + +**Files:** +- Modify: `crates/idfoto-core/src/crypto.rs` + +- [ ] **Step 1: Add failing test for length-prefix construction** + +Append inside the existing `#[cfg(test)] mod tests` block in `crates/idfoto-core/src/crypto.rs`: + +```rust +#[test] +fn length_prefix_eliminates_concatenation_ambiguity() { + // Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide. + // With length-prefix: distinct inputs always yield distinct keys. + let salt = [0u8; 32]; + let params = fast_params(); + + // Pair A: passphrase "abc", image_secret starts with 0x44 + let mut img_a = [0u8; 32]; img_a[0] = 0x44; + let key_a = derive_master_key(b"abc", &img_a, &salt, ¶ms).unwrap(); + + // Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1 + let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image + let key_b = derive_master_key(b"abcD", &img_b, &salt, ¶ms).unwrap(); + + // With length-prefix, the keys MUST differ. + assert_ne!(*key_a, *key_b); +} + +#[test] +fn nfc_normalization_collapses_unicode_forms() { + // "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301). + // Both must produce the same key after NFC normalization. + let salt = [0u8; 32]; + let img = [0u8; 32]; + let params = fast_params(); + + let nfc = "caf\u{00e9}".as_bytes(); // é precomposed + let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute + + let key_nfc = derive_master_key(nfc, &img, &salt, ¶ms).unwrap(); + let key_nfd = derive_master_key(nfd, &img, &salt, ¶ms).unwrap(); + + assert_eq!(*key_nfc, *key_nfd); +} + +#[test] +fn master_key_is_zeroized_on_drop() { + // Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if + // we wrap correctly. The drop wipe is verified by the zeroize crate's tests. + let salt = [0u8; 32]; + let img = [0u8; 32]; + let params = fast_params(); + let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, ¶ms).unwrap(); + assert_eq!(key.len(), 32); +} +``` + +- [ ] **Step 2: Run tests, expect fail (signature still returns `[u8; 32]`)** + +Run: `cargo test -p idfoto-core --lib crypto::tests::length_prefix_eliminates_concatenation_ambiguity 2>&1 | tail -10` +Expected: compile error about `Zeroizing<[u8; 32]>` mismatch with current `[u8; 32]` return; or the NFC test fails because no normalization is applied. + +- [ ] **Step 3: Update `derive_master_key` signature and body** + +Replace the `derive_master_key` function in `crates/idfoto-core/src/crypto.rs` with: + +```rust +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; + +pub fn derive_master_key( + passphrase: &[u8], + image_secret: &[u8; 32], + salt: &[u8; 32], + params: &KdfParams, +) -> Result> { + let argon2_params = Params::new( + params.argon2_m, + params.argon2_t, + params.argon2_p, + Some(32), + ) + .map_err(|e| IdfotoError::Kdf(e.to_string()))?; + + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params); + + // Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged. + let nfc_passphrase: Vec = match std::str::from_utf8(passphrase) { + Ok(s) => s.nfc().collect::().into_bytes(), + Err(_) => passphrase.to_vec(), + }; + + // Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase] + // [u64_be(32)][image_secret] + // Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1). + let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32)); + password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes()); + password.extend_from_slice(&nfc_passphrase); + password.extend_from_slice(&32u64.to_be_bytes()); + password.extend_from_slice(image_secret); + + let mut output = Zeroizing::new([0u8; 32]); + argon2 + .hash_password_into(password.as_slice(), salt, output.as_mut()) + .map_err(|e| IdfotoError::Kdf(e.to_string()))?; + + Ok(output) +} +``` + +Existing tests will need their `derive_master_key` results dereferenced — the original assertions `assert_eq!(key1, key2)` still work because `Zeroizing<[u8; 32]>` derefs to `[u8; 32]` and `PartialEq` propagates. If any test fails to compile, change `assert_eq!(key1, key2)` to `assert_eq!(*key1, *key2)`. + +- [ ] **Step 4: Run all crypto tests** + +Run: `cargo test -p idfoto-core --lib crypto:: 2>&1 | tail -30` +Expected: all crypto tests pass (including the existing ones and the three new ones). + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-core/src/crypto.rs +git commit -m "$(cat <<'EOF' +feat(core): length-prefixed Argon2 input + NFC + Zeroize (audit H1, H2) + +derive_master_key now: +- length-prefixes passphrase and image_secret to eliminate concatenation + ambiguity (H1) +- normalizes passphrase to UTF-8 NFC before hashing +- returns Zeroizing<[u8; 32]> so the master key is wiped on drop (H2) +- wraps the intermediate password buffer in Zeroizing for the same reason + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Crypto — VERSION_BYTE bump to 0x02 and explicit version error + +**Files:** +- Modify: `crates/idfoto-core/src/crypto.rs` + +- [ ] **Step 1: Find the current VERSION_BYTE declaration** + +Run: `grep -n VERSION_BYTE crates/idfoto-core/src/crypto.rs` +Expected: a const like `pub const VERSION_BYTE: u8 = 0x01;` and references in `encrypt`/`decrypt`. + +- [ ] **Step 2: Add failing test** + +Append in the `crypto::tests` block: + +```rust +#[test] +fn version_byte_is_0x02() { + assert_eq!(VERSION_BYTE, 0x02); +} + +#[test] +fn decrypt_rejects_v1_blob_with_typed_error() { + // Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes]. + let mut blob = vec![0x01u8]; + blob.extend_from_slice(&[0u8; 24]); + blob.extend_from_slice(&[0u8; 16]); + + let key = Zeroizing::new([0u8; 32]); + let err = decrypt(&blob, &key).expect_err("v1 blob should fail decrypt"); + match err { + IdfotoError::UnsupportedFormatVersion { found, expected } => { + assert_eq!(found, 0x01); + assert_eq!(expected, 0x02); + } + other => panic!("expected UnsupportedFormatVersion, got {:?}", other), + } +} +``` + +- [ ] **Step 3: Run tests, expect fail** + +Run: `cargo test -p idfoto-core --lib crypto::tests::version_byte_is_0x02 crypto::tests::decrypt_rejects_v1_blob_with_typed_error 2>&1 | tail -20` +Expected: first test fails (still 0x01), second test fails (current code returns `IdfotoError::Format(...)`). + +- [ ] **Step 4: Update VERSION_BYTE and decrypt's version check** + +In `crates/idfoto-core/src/crypto.rs`: + +1. Change `pub const VERSION_BYTE: u8 = 0x01;` → `pub const VERSION_BYTE: u8 = 0x02;`. +2. In `decrypt`, find the version-byte check (likely something like `if data[0] != VERSION_BYTE { return Err(IdfotoError::Format(...)) }`) and replace with: + +```rust +if data[0] != VERSION_BYTE { + return Err(IdfotoError::UnsupportedFormatVersion { + found: data[0], + expected: VERSION_BYTE, + }); +} +``` + +3. Update the `decrypt` signature to take `&Zeroizing<[u8; 32]>` instead of `&[u8; 32]` (so it composes with the new KDF return). Or keep `&[u8; 32]` and have callers `&*key` — pick the simpler call-site experience. **Recommendation: keep `&[u8; 32]` and have callers deref** — it minimizes churn. + +- [ ] **Step 5: Run tests, expect pass** + +Run: `cargo test -p idfoto-core --lib crypto:: 2>&1 | tail -30` +Expected: all crypto tests pass. Existing round-trip tests using v0x01-encrypted fixtures may fail; those aren't expected to exist in-repo (tests generate fresh blobs), so this should be clean. If any pre-existing test fails because it hardcoded `0x01`, update to `0x02`. + +- [ ] **Step 6: Commit** + +```bash +git add crates/idfoto-core/src/crypto.rs +git commit -m "$(cat <<'EOF' +feat(core): bump VERSION_BYTE to 0x02 with typed UnsupportedFormatVersion + +Clean break from v1 — no migration. Decrypting a v1 blob now returns +IdfotoError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: ItemType + ItemCore enum scaffold + +**Files:** +- Create: `crates/idfoto-core/src/item_types/mod.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write `src/item_types/mod.rs` with placeholder cores** + +```rust +//! Per-type "core" structs for typed items. +//! +//! Each variant lives in its own submodule. The `ItemCore` enum + match +//! exhaustiveness is the extension mechanism — adding a new variant later +//! means: create the submodule, add the enum variant, fix the match arms +//! the compiler points at, register the popup form (Plan 1C). + +use serde::{Deserialize, Serialize}; + +pub mod login; +pub mod secure_note; +pub mod identity; +pub mod card; +pub mod key; +pub mod document; +pub mod totp; + +pub use login::LoginCore; +pub use secure_note::SecureNoteCore; +pub use identity::IdentityCore; +pub use card::{CardCore, CardKind}; +pub use key::KeyCore; +pub use document::DocumentCore; +pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemType { + Login, + SecureNote, + Identity, + Card, + Key, + Document, + Totp, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ItemCore { + Login(LoginCore), + SecureNote(SecureNoteCore), + Identity(IdentityCore), + Card(CardCore), + Key(KeyCore), + Document(DocumentCore), + Totp(TotpCore), +} + +impl ItemCore { + pub fn item_type(&self) -> ItemType { + match self { + ItemCore::Login(_) => ItemType::Login, + ItemCore::SecureNote(_) => ItemType::SecureNote, + ItemCore::Identity(_) => ItemType::Identity, + ItemCore::Card(_) => ItemType::Card, + ItemCore::Key(_) => ItemType::Key, + ItemCore::Document(_) => ItemType::Document, + ItemCore::Totp(_) => ItemType::Totp, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_type_serializes_snake_case() { + let json = serde_json::to_string(&ItemType::SecureNote).unwrap(); + assert_eq!(json, "\"secure_note\""); + } +} +``` + +- [ ] **Step 2: Create empty submodule files (one per type) with `pub struct` stubs so `mod.rs` compiles** + +Each file gets a minimal struct stub that we'll flesh out in subsequent tasks. The compiler must be happy after this step. + +`crates/idfoto-core/src/item_types/login.rs`: +```rust +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LoginCore {} +``` + +`crates/idfoto-core/src/item_types/secure_note.rs`: +```rust +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SecureNoteCore {} +``` + +`crates/idfoto-core/src/item_types/identity.rs`: +```rust +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IdentityCore {} +``` + +`crates/idfoto-core/src/item_types/card.rs`: +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CardCore {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum CardKind { + #[default] + Credit, + Debit, + Gift, + Loyalty, + Other, +} +``` + +`crates/idfoto-core/src/item_types/key.rs`: +```rust +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct KeyCore {} +``` + +`crates/idfoto-core/src/item_types/document.rs`: +```rust +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DocumentCore {} +``` + +`crates/idfoto-core/src/item_types/totp.rs`: +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TotpCore {} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TotpConfig {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TotpAlgorithm { + #[default] + Sha1, + Sha256, + Sha512, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TotpKind { + Totp, + Hotp { counter: u64 }, + Steam, +} + +impl Default for TotpKind { + fn default() -> Self { TotpKind::Totp } +} +``` + +- [ ] **Step 3: Wire item_types into lib.rs** + +Add to `crates/idfoto-core/src/lib.rs` after the `pub mod time;` line: + +```rust +pub mod item_types; +pub use item_types::{ItemCore, ItemType}; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types:: 2>&1 | tail -10` +Expected: 1 test passed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-core/src/item_types/ crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): scaffold item_types module with ItemType + ItemCore enum + +Stub LoginCore, SecureNoteCore, IdentityCore, CardCore, KeyCore, +DocumentCore, TotpCore. Tag-based serde representation with snake_case +discriminants. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Flesh out LoginCore + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/login.rs` + +- [ ] **Step 1: Write failing tests in `login.rs`** + +Replace `crates/idfoto-core/src/item_types/login.rs` with: + +```rust +//! Login item core: username, password (Zeroizing), URL, optional TOTP. + +use serde::{Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use crate::item_types::TotpConfig; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LoginCore { + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub totp: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_login_round_trips() { + let login = LoginCore::default(); + let json = serde_json::to_string(&login).unwrap(); + let parsed: LoginCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.username.is_none()); + assert!(parsed.password.is_none()); + } + + #[test] + fn full_login_round_trips() { + let login = LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com/login").unwrap()), + totp: None, + }; + let json = serde_json::to_string(&login).unwrap(); + let parsed: LoginCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.username.as_deref(), Some("alice")); + assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2")); + assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login")); + } + + #[test] + fn omitted_fields_dont_appear_in_json() { + let login = LoginCore { + username: Some("alice".into()), + password: None, + url: None, + totp: None, + }; + let json = serde_json::to_string(&login).unwrap(); + assert!(!json.contains("password")); + assert!(!json.contains("url")); + assert!(!json.contains("totp")); + assert!(json.contains("alice")); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types::login 2>&1 | tail -20` +Expected: 3 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/login.rs +git commit -m "$(cat <<'EOF' +feat(core): flesh out LoginCore with Zeroizing and Url + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Flesh out SecureNoteCore + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/secure_note.rs` + +- [ ] **Step 1: Write tests + impl together (small file)** + +Replace `secure_note.rs`: + +```rust +//! Secure note: just a multiline body, Zeroizing. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SecureNoteCore { + pub body: Zeroizing, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secure_note_round_trips() { + let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) }; + let json = serde_json::to_string(¬e).unwrap(); + let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.body.as_str(), "a multi\nline note"); + } + + #[test] + fn empty_body_round_trips() { + let note = SecureNoteCore::default(); + let json = serde_json::to_string(¬e).unwrap(); + let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.body.is_empty()); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types::secure_note 2>&1 | tail -10` +Expected: 2 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/secure_note.rs +git commit -m "feat(core): flesh out SecureNoteCore (Zeroizing body) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 9: Flesh out IdentityCore + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/identity.rs` + +- [ ] **Step 1: Write tests + impl** + +Replace `identity.rs`: + +```rust +//! Identity: name, address, phone, email, date-of-birth. + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct IdentityCore { + #[serde(skip_serializing_if = "Option::is_none")] + pub full_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date_of_birth: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_full_round_trip() { + let id = IdentityCore { + full_name: Some("Alice Doe".into()), + address: Some("123 Main St\nAnytown".into()), + phone: Some("+1-555-0100".into()), + email: Some("alice@example.com".into()), + date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18), + }; + let json = serde_json::to_string(&id).unwrap(); + let parsed: IdentityCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe")); + assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18)); + } + + #[test] + fn empty_identity_omits_all_fields() { + let id = IdentityCore::default(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "{}"); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types::identity 2>&1 | tail -10` +Expected: 2 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/identity.rs +git commit -m "feat(core): flesh out IdentityCore + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 10: Flesh out CardCore + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/card.rs` + +- [ ] **Step 1: Write tests + impl** + +Replace `card.rs`: + +```rust +//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::time::MonthYear; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CardCore { + #[serde(skip_serializing_if = "Option::is_none")] + pub number: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub holder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cvv: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin: Option>, + #[serde(default)] + pub kind: CardKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum CardKind { + #[default] + Credit, + Debit, + Gift, + Loyalty, + Other, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_full_round_trip() { + let card = CardCore { + number: Some(Zeroizing::new("4111111111111111".into())), + holder: Some("Alice Doe".into()), + expiry: Some(MonthYear::new(12, 2030).unwrap()), + cvv: Some(Zeroizing::new("123".into())), + pin: Some(Zeroizing::new("0000".into())), + kind: CardKind::Credit, + }; + let json = serde_json::to_string(&card).unwrap(); + let parsed: CardCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.holder.as_deref(), Some("Alice Doe")); + assert_eq!(parsed.kind, CardKind::Credit); + assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap())); + } + + #[test] + fn card_kind_default_is_credit() { + let json = "{}"; + let card: CardCore = serde_json::from_str(json).unwrap(); + assert_eq!(card.kind, CardKind::Credit); + } + + #[test] + fn card_kind_serializes_snake_case() { + let json = serde_json::to_string(&CardKind::Loyalty).unwrap(); + assert_eq!(json, "\"loyalty\""); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types::card 2>&1 | tail -10` +Expected: 3 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/card.rs +git commit -m "feat(core): flesh out CardCore + CardKind + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 11: Flesh out KeyCore + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/key.rs` + +- [ ] **Step 1: Write tests + impl** + +```rust +//! Key: arbitrary key material (Zeroizing), label, public key, algorithm. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct KeyCore { + pub key_material: Zeroizing, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub algorithm: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_round_trip() { + let k = KeyCore { + key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()), + label: Some("yubikey-backup".into()), + public_key: Some("ssh-ed25519 AAAAC3...".into()), + algorithm: Some("ed25519".into()), + }; + let json = serde_json::to_string(&k).unwrap(); + let parsed: KeyCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.key_material.starts_with("-----BEGIN")); + assert_eq!(parsed.algorithm.as_deref(), Some("ed25519")); + } + + #[test] + fn empty_key_material_round_trips() { + let k = KeyCore::default(); + let json = serde_json::to_string(&k).unwrap(); + let parsed: KeyCore = serde_json::from_str(&json).unwrap(); + assert!(parsed.key_material.is_empty()); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types::key 2>&1 | tail -10` +Expected: 2 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/key.rs +git commit -m "feat(core): flesh out KeyCore + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 12: Flesh out DocumentCore + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/document.rs` + +- [ ] **Step 1: Write tests + impl** + +```rust +//! Document: filename + mime + pointer to the primary attachment blob. + +use serde::{Deserialize, Serialize}; + +use crate::ids::AttachmentId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentCore { + pub filename: String, + pub mime_type: String, + pub primary_attachment: AttachmentId, +} + +impl Default for DocumentCore { + fn default() -> Self { + Self { + filename: String::new(), + mime_type: "application/octet-stream".into(), + primary_attachment: AttachmentId(String::new()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_round_trip() { + let doc = DocumentCore { + filename: "passport.pdf".into(), + mime_type: "application/pdf".into(), + primary_attachment: AttachmentId("0123456789abcdef".into()), + }; + let json = serde_json::to_string(&doc).unwrap(); + let parsed: DocumentCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.filename, "passport.pdf"); + assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef"); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types::document 2>&1 | tail -10` +Expected: 1 test passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/document.rs +git commit -m "feat(core): flesh out DocumentCore + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 13: Flesh out TotpCore + TotpConfig + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/totp.rs` + +- [ ] **Step 1: Write tests + impl** + +Replace `totp.rs`: + +```rust +//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TotpCore { + pub config: TotpConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TotpConfig { + /// Raw bytes of the TOTP secret (decoded from base32 when imported). + pub secret: Zeroizing>, + pub algorithm: TotpAlgorithm, + pub digits: u8, + pub period_seconds: u32, + pub kind: TotpKind, +} + +impl Default for TotpConfig { + fn default() -> Self { + Self { + secret: Zeroizing::new(Vec::new()), + algorithm: TotpAlgorithm::Sha1, + digits: 6, + period_seconds: 30, + kind: TotpKind::Totp, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TotpAlgorithm { + #[default] + Sha1, + Sha256, + Sha512, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TotpKind { + Totp, + Hotp { counter: u64 }, + Steam, +} + +impl Default for TotpKind { + fn default() -> Self { TotpKind::Totp } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn totp_default_is_sha1_6_30_totp() { + let cfg = TotpConfig::default(); + assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1); + assert_eq!(cfg.digits, 6); + assert_eq!(cfg.period_seconds, 30); + assert_eq!(cfg.kind, TotpKind::Totp); + } + + #[test] + fn totp_round_trip() { + let core = TotpCore { + config: TotpConfig { + secret: Zeroizing::new(vec![0x12, 0x34, 0x56]), + algorithm: TotpAlgorithm::Sha256, + digits: 8, + period_seconds: 60, + kind: TotpKind::Totp, + }, + issuer: Some("github".into()), + label: Some("alice@github".into()), + }; + let json = serde_json::to_string(&core).unwrap(); + let parsed: TotpCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.config.digits, 8); + assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256); + assert_eq!(parsed.issuer.as_deref(), Some("github")); + } + + #[test] + fn hotp_carries_counter() { + let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() }; + let json = serde_json::to_string(&cfg).unwrap(); + let parsed: TotpConfig = serde_json::from_str(&json).unwrap(); + match parsed.kind { + TotpKind::Hotp { counter } => assert_eq!(counter, 42), + other => panic!("expected Hotp, got {:?}", other), + } + } + + #[test] + fn steam_kind_serializes() { + let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() }; + let json = serde_json::to_string(&cfg).unwrap(); + assert!(json.contains("steam")); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types::totp 2>&1 | tail -10` +Expected: 4 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/totp.rs +git commit -m "feat(core): flesh out TotpCore + TotpConfig + TotpAlgorithm + TotpKind + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 14: ItemCore round-trip test + +**Files:** +- Modify: `crates/idfoto-core/src/item_types/mod.rs` + +- [ ] **Step 1: Add test exercising the tag-based ItemCore enum** + +Append to the `tests` module in `crates/idfoto-core/src/item_types/mod.rs`: + +```rust +#[test] +fn item_core_login_round_trip_via_tag() { + use zeroize::Zeroizing; + let core = ItemCore::Login(LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: None, + totp: None, + }); + let json = serde_json::to_string(&core).unwrap(); + // Tag-based: outer object has "type": "login" + assert!(json.contains("\"type\":\"login\"")); + let parsed: ItemCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.item_type(), ItemType::Login); +} + +#[test] +fn item_core_secure_note_round_trip_via_tag() { + use zeroize::Zeroizing; + let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) }); + let json = serde_json::to_string(&core).unwrap(); + assert!(json.contains("\"type\":\"secure_note\"")); + let parsed: ItemCore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.item_type(), ItemType::SecureNote); +} + +#[test] +fn item_core_round_trips_for_all_seven_types() { + use zeroize::Zeroizing; + use crate::ids::AttachmentId; + + let cores = vec![ + ItemCore::Login(LoginCore::default()), + ItemCore::SecureNote(SecureNoteCore::default()), + ItemCore::Identity(IdentityCore::default()), + ItemCore::Card(CardCore::default()), + ItemCore::Key(KeyCore::default()), + ItemCore::Document(DocumentCore { + filename: "x".into(), + mime_type: "text/plain".into(), + primary_attachment: AttachmentId("0123456789abcdef".into()), + }), + ItemCore::Totp(TotpCore::default()), + ]; + for core in cores { + let expected_type = core.item_type(); + let json = serde_json::to_string(&core).unwrap(); + let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed"); + assert_eq!(parsed.item_type(), expected_type); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p idfoto-core --lib item_types:: 2>&1 | tail -20` +Expected: all item_types tests pass (including the 3 new ones). + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/src/item_types/mod.rs +git commit -m "test(core): exhaustive round-trip for all seven ItemCore variants + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 15: FieldKind + FieldValue + Field + +**Files:** +- Create: `crates/idfoto-core/src/item.rs` (partial — Field/FieldKind/FieldValue/Section first) +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write `src/item.rs` with FieldKind/FieldValue/Field/Section** + +```rust +//! Item envelope, sections, and custom fields. +//! +//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing +//! to a single tagged enum) so the kind can be queried without inspecting the value. +//! Validation invariant: kind and value's discriminants must match — enforced at +//! construction (`Field::new`) and during deserialization (`Field::validate`). + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use crate::error::{IdfotoError, Result}; +use crate::ids::{AttachmentId, FieldId}; +use crate::item_types::TotpConfig; +use crate::time::MonthYear; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldKind { + Text, + Multiline, + Password, + Concealed, + Url, + Email, + Phone, + Date, + MonthYear, + Totp, + Reference, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum FieldValue { + Text(String), + Multiline(String), + Password(Zeroizing), + Concealed(Zeroizing), + Url(Url), + Email(String), + Phone(String), + Date(NaiveDate), + MonthYear(MonthYear), + Totp(TotpConfig), + Reference(AttachmentId), +} + +impl FieldValue { + pub fn kind(&self) -> FieldKind { + match self { + FieldValue::Text(_) => FieldKind::Text, + FieldValue::Multiline(_) => FieldKind::Multiline, + FieldValue::Password(_) => FieldKind::Password, + FieldValue::Concealed(_) => FieldKind::Concealed, + FieldValue::Url(_) => FieldKind::Url, + FieldValue::Email(_) => FieldKind::Email, + FieldValue::Phone(_) => FieldKind::Phone, + FieldValue::Date(_) => FieldKind::Date, + FieldValue::MonthYear(_) => FieldKind::MonthYear, + FieldValue::Totp(_) => FieldKind::Totp, + FieldValue::Reference(_) => FieldKind::Reference, + } + } + + /// True if this kind triggers field-history capture on update. + pub fn is_history_tracked(&self) -> bool { + matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Field { + pub id: FieldId, + pub label: String, + pub kind: FieldKind, + pub value: FieldValue, + #[serde(default)] + pub hidden_by_default: bool, +} + +impl Field { + /// Construct a field, deriving `kind` from `value`. + pub fn new(label: String, value: FieldValue) -> Self { + let kind = value.kind(); + Self { + id: FieldId::new(), + label, + kind, + value, + hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed), + } + } + + /// Verify kind/value discriminants match. Called after deserialization. + pub fn validate(&self) -> Result<()> { + if self.kind != self.value.kind() { + return Err(IdfotoError::Format(format!( + "field {}: kind {:?} does not match value discriminant {:?}", + self.id.as_str(), + self.kind, + self.value.kind() + ))); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Section { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub fields: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_value_kind_matches() { + let v = FieldValue::Text("hello".into()); + assert_eq!(v.kind(), FieldKind::Text); + } + + #[test] + fn password_field_marked_history_tracked() { + assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked()); + assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked()); + assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked()); + assert!(!FieldValue::Text("x".into()).is_history_tracked()); + assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked()); + } + + #[test] + fn field_new_derives_kind_from_value() { + let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into()))); + assert_eq!(f.kind, FieldKind::Password); + assert!(f.hidden_by_default); + } + + #[test] + fn field_new_text_not_hidden() { + let f = Field::new("Username".into(), FieldValue::Text("alice".into())); + assert!(!f.hidden_by_default); + } + + #[test] + fn field_validate_catches_kind_value_mismatch() { + let f = Field { + id: FieldId::new(), + label: "x".into(), + kind: FieldKind::Password, + value: FieldValue::Text("not actually a password".into()), + hidden_by_default: false, + }; + assert!(f.validate().is_err()); + } + + #[test] + fn field_round_trips() { + let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into()))); + let json = serde_json::to_string(&f).unwrap(); + let parsed: Field = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.label, "Recovery code"); + assert_eq!(parsed.kind, FieldKind::Concealed); + parsed.validate().unwrap(); + } + + #[test] + fn section_round_trip() { + let s = Section { + name: Some("Recovery codes".into()), + fields: vec![ + Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))), + Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))), + ], + }; + let json = serde_json::to_string(&s).unwrap(); + let parsed: Section = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("Recovery codes")); + assert_eq!(parsed.fields.len(), 2); + } +} +``` + +- [ ] **Step 2: Wire into lib.rs** + +Add after `pub mod item_types;`: + +```rust +pub mod item; +pub use item::{Field, FieldKind, FieldValue, Section}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p idfoto-core --lib item:: 2>&1 | tail -20` +Expected: 7 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/item.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add Field, FieldKind, FieldValue, Section + +Parallel kind/value enums with a validate() invariant. Password, +Concealed, and Totp kinds are marked history-tracked so the Item setter +(next task) can decide whether to capture history on update. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 16: AttachmentRef + AttachmentSummary + +**Files:** +- Create: `crates/idfoto-core/src/attachment.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write `src/attachment.rs`** + +```rust +//! Attachment refs (carried on Item) and summaries (carried in Manifest). +//! +//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added +//! later in Task 22 once the crypto module is settled. + +use serde::{Deserialize, Serialize}; + +use crate::ids::AttachmentId; + +/// Reference to an attachment, carried on the Item record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentRef { + pub id: AttachmentId, + pub filename: String, + pub mime_type: String, + /// Plaintext size in bytes. + pub size: u64, + /// Unix-seconds when this attachment was added. + pub created: i64, +} + +/// Compact summary of an attachment, carried in the Manifest so the popup +/// can show attachment indicators without decrypting the item file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentSummary { + pub id: AttachmentId, + pub filename: String, + pub mime_type: String, + pub size: u64, +} + +impl From<&AttachmentRef> for AttachmentSummary { + fn from(r: &AttachmentRef) -> Self { + Self { + id: r.id.clone(), + filename: r.filename.clone(), + mime_type: r.mime_type.clone(), + size: r.size, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attachment_ref_round_trip() { + let r = AttachmentRef { + id: AttachmentId("0123456789abcdef".into()), + filename: "doc.pdf".into(), + mime_type: "application/pdf".into(), + size: 12345, + created: 1_700_000_000, + }; + let json = serde_json::to_string(&r).unwrap(); + let parsed: AttachmentRef = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.filename, "doc.pdf"); + assert_eq!(parsed.size, 12345); + } + + #[test] + fn attachment_summary_from_ref() { + let r = AttachmentRef { + id: AttachmentId("aabb".into()), + filename: "x.txt".into(), + mime_type: "text/plain".into(), + size: 5, + created: 0, + }; + let s: AttachmentSummary = (&r).into(); + assert_eq!(s.filename, "x.txt"); + assert_eq!(s.id, r.id); + } +} +``` + +- [ ] **Step 2: Wire into lib.rs** + +Add after `pub mod item;`: + +```rust +pub mod attachment; +pub use attachment::{AttachmentRef, AttachmentSummary}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p idfoto-core --lib attachment:: 2>&1 | tail -10` +Expected: 2 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/attachment.rs crates/idfoto-core/src/lib.rs +git commit -m "feat(core): add AttachmentRef + AttachmentSummary + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 17: Item envelope + field history + +**Files:** +- Modify: `crates/idfoto-core/src/item.rs` + +- [ ] **Step 1: Append Item, FieldHistoryEntry, and the setter** + +Append to `crates/idfoto-core/src/item.rs` (after the existing Section definition, before the test module): + +```rust +use std::collections::HashMap; + +use crate::attachment::AttachmentRef; +use crate::ids::ItemId; +use crate::item_types::{ItemCore, ItemType}; +use crate::time::now_unix; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldHistoryEntry { + pub value: Zeroizing, + pub replaced_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: ItemId, + pub title: String, + pub r#type: ItemType, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created: i64, + pub modified: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub trashed_at: Option, + pub core: ItemCore, + #[serde(default)] + pub sections: Vec
, + #[serde(default)] + pub attachments: Vec, + #[serde(default)] + pub field_history: HashMap>, +} + +impl Item { + /// Construct a new Item from a typed core; auto-fills id, type, timestamps. + pub fn new(title: String, core: ItemCore) -> Self { + let now = now_unix(); + let r#type = core.item_type(); + Self { + id: ItemId::new(), + title, + r#type, + tags: Vec::new(), + favorite: false, + group: None, + notes: None, + created: now, + modified: now, + trashed_at: None, + core, + sections: Vec::new(), + attachments: Vec::new(), + field_history: HashMap::new(), + } + } + + /// Replace a custom field's value, capturing the previous value into + /// field_history if the field's kind is history-tracked. + pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> { + for section in &mut self.sections { + if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) { + if field.value.kind() != new_value.kind() { + return Err(IdfotoError::Format(format!( + "field {}: cannot change kind from {:?} to {:?}", + field.id.as_str(), field.value.kind(), new_value.kind() + ))); + } + if field.value.is_history_tracked() { + let serialized = serialize_history_value(&field.value)?; + self.field_history + .entry(field.id.clone()) + .or_default() + .push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() }); + } + field.value = new_value; + self.modified = now_unix(); + return Ok(()); + } + } + Err(IdfotoError::Format(format!("field {} not found", field_id.as_str()))) + } + + pub fn soft_delete(&mut self) { + self.trashed_at = Some(now_unix()); + self.modified = now_unix(); + } + + pub fn restore(&mut self) { + self.trashed_at = None; + self.modified = now_unix(); + } + + pub fn is_trashed(&self) -> bool { + self.trashed_at.is_some() + } +} + +/// Serialize a FieldValue to the string form stored in field_history. +fn serialize_history_value(value: &FieldValue) -> Result> { + let s = match value { + FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()), + FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()), + FieldValue::Totp(cfg) => { + // Store the base32-encoded secret string for human-recognizability. + let s = base32_encode(&cfg.secret); + Zeroizing::new(s) + } + _ => return Err(IdfotoError::Format("not a history-tracked kind".into())), + }; + Ok(s) +} + +/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization. +fn base32_encode(bytes: &[u8]) -> String { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::new(); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for &b in bytes { + buffer = (buffer << 8) | (b as u32); + bits += 8; + while bits >= 5 { + let idx = ((buffer >> (bits - 5)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + bits -= 5; + } + } + if bits > 0 { + let idx = ((buffer << (5 - bits)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + } + out +} +``` + +- [ ] **Step 2: Append tests for Item** + +Append to the existing `tests` module in `crates/idfoto-core/src/item.rs`: + +```rust +#[test] +fn new_item_has_timestamps_and_id() { + let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default()); + let item = Item::new("note".into(), core); + assert_eq!(item.id.0.len(), 16); + assert_eq!(item.r#type, ItemType::SecureNote); + assert!(item.created > 0); + assert_eq!(item.created, item.modified); + assert!(item.field_history.is_empty()); +} + +#[test] +fn soft_delete_and_restore_round_trip() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + assert!(!item.is_trashed()); + item.soft_delete(); + assert!(item.is_trashed()); + item.restore(); + assert!(!item.is_trashed()); +} + +#[test] +fn set_field_value_captures_history_for_password() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into()))); + let pw_id = pw_field.id.clone(); + item.sections.push(Section { name: None, fields: vec![pw_field] }); + + item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap(); + let hist = item.field_history.get(&pw_id).expect("history should exist"); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].value.as_str(), "old"); +} + +#[test] +fn set_field_value_does_not_capture_history_for_text() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let f = Field::new("nickname".into(), FieldValue::Text("a".into())); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap(); + assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty())); +} + +#[test] +fn set_field_value_rejects_kind_change() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let f = Field::new("x".into(), FieldValue::Text("a".into())); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into()))); + assert!(err.is_err()); +} + +#[test] +fn item_serializes_with_minimal_optional_fields() { + let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default()); + let item = Item::new("note".into(), core); + let json = serde_json::to_string(&item).unwrap(); + // No "trashed_at" or "group" or "notes" should appear when None + assert!(!json.contains("trashed_at")); + assert!(!json.contains("\"group\"")); +} + +#[test] +fn full_item_round_trip() { + let core = ItemCore::Login(crate::item_types::LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com").unwrap()), + totp: None, + }); + let mut item = Item::new("GitHub".into(), core); + item.tags = vec!["work".into()]; + item.favorite = true; + item.notes = Some("notes".into()); + + let json = serde_json::to_string(&item).unwrap(); + let parsed: Item = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.title, "GitHub"); + assert_eq!(parsed.tags, vec!["work".to_string()]); + assert!(parsed.favorite); + match parsed.core { + ItemCore::Login(l) => { + assert_eq!(l.username.as_deref(), Some("alice")); + } + other => panic!("expected Login, got {:?}", other), + } +} +``` + +- [ ] **Step 3: Wire into lib.rs** + +Add `Item, FieldHistoryEntry` to the existing item re-export line: + +```rust +pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p idfoto-core --lib item:: 2>&1 | tail -30` +Expected: all item tests pass (7 original + 7 new = 14). + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-core/src/item.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add Item envelope with field history + soft-delete + +set_field_value() captures old values for Password, Concealed, and Totp +kinds. Soft-delete via trashed_at timestamp; restore clears it. Kind +changes on set_field_value are rejected. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 18: Manifest rewrite + +**Files:** +- Modify: `crates/idfoto-core/src/manifest.rs` (rename from a future migration; for now write to a new file) +- Create: `crates/idfoto-core/src/manifest.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +> Note: `entry.rs` currently holds the old Manifest. Don't touch it until Task 25 — it'll keep compiling alongside the new module. + +- [ ] **Step 1: Write `src/manifest.rs`** + +```rust +//! New typed-item manifest. Lives next to the old entry.rs Manifest +//! during this rewrite; entry.rs is deleted in Task 25. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::attachment::AttachmentSummary; +use crate::ids::ItemId; +use crate::item::Item; +use crate::item_types::ItemType; + +pub const MANIFEST_SCHEMA_VERSION: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub schema_version: u32, + pub items: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + pub id: ItemId, + pub r#type: ItemType, + pub title: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_hint: Option, + pub modified: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub trashed_at: Option, + #[serde(default)] + pub attachment_summaries: Vec, +} + +impl Manifest { + pub fn new() -> Self { + Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() } + } + + pub fn upsert(&mut self, item: &Item) { + let entry = ManifestEntry::from_item(item); + self.items.insert(item.id.clone(), entry); + } + + pub fn remove(&mut self, id: &ItemId) -> Option { + self.items.remove(id) + } + + pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> { + self.items.get(id) + } + + /// Case-insensitive substring match on title and tags. + pub fn search(&self, query: &str) -> Vec<&ManifestEntry> { + let q = query.to_lowercase(); + self.items + .values() + .filter(|e| { + e.title.to_lowercase().contains(&q) + || e.tags.iter().any(|t| t.to_lowercase().contains(&q)) + }) + .collect() + } +} + +impl Default for Manifest { + fn default() -> Self { Self::new() } +} + +impl ManifestEntry { + pub fn from_item(item: &Item) -> Self { + Self { + id: item.id.clone(), + r#type: item.r#type, + title: item.title.clone(), + tags: item.tags.clone(), + favorite: item.favorite, + group: item.group.clone(), + icon_hint: derive_icon_hint(item), + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.iter().map(Into::into).collect(), + } + } +} + +/// Derive an icon hint string from an item — for Login items, this is the URL hostname. +fn derive_icon_hint(item: &Item) -> Option { + use crate::item_types::ItemCore; + match &item.core { + ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::item_types::{ItemCore, LoginCore, SecureNoteCore}; + + #[test] + fn empty_manifest_has_schema_v2() { + let m = Manifest::new(); + assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION); + assert!(m.items.is_empty()); + } + + #[test] + fn upsert_and_search() { + let mut m = Manifest::new(); + let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default())); + item.tags = vec!["work".into()]; + m.upsert(&item); + + let results = m.search("github"); + assert_eq!(results.len(), 1); + let by_tag = m.search("work"); + assert_eq!(by_tag.len(), 1); + } + + #[test] + fn icon_hint_is_login_url_host() { + use url::Url; + let mut m = Manifest::new(); + let core = ItemCore::Login(LoginCore { + url: Some(Url::parse("https://api.github.com/login").unwrap()), + ..Default::default() + }); + let item = Item::new("X".into(), core); + m.upsert(&item); + let entry = m.items.values().next().unwrap(); + assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com")); + } + + #[test] + fn icon_hint_is_none_for_non_login() { + let mut m = Manifest::new(); + let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let entry = m.items.values().next().unwrap(); + assert!(entry.icon_hint.is_none()); + } + + #[test] + fn manifest_round_trips() { + let mut m = Manifest::new(); + let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let json = serde_json::to_string(&m).unwrap(); + let parsed: Manifest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION); + assert_eq!(parsed.items.len(), 1); + } +} + +use crate::item::Item as _; // ensure import compiles even if unused above +``` + +The trailing `use` is paranoid — remove it if it triggers a warning. + +- [ ] **Step 2: Wire into lib.rs** + +Add after `pub mod attachment;`: + +```rust +pub mod manifest; +pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION}; +``` + +This will conflict with the existing `pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};` line. Since `entry.rs` is going away in Task 25, **remove the conflicting types from the entry re-export now**: + +Change the existing `pub use entry::{...}` line to: + +```rust +pub use entry::{generate_entry_id, Entry}; +``` + +Anything else that imports `Manifest` or `ManifestEntry` directly from `idfoto_core::entry` will break. Find and update with: + +```bash +grep -rn "idfoto_core::entry::\(Manifest\|ManifestEntry\)\|entry::\(Manifest\|ManifestEntry\)" crates/idfoto-core/src/ +``` + +Update those imports to `crate::manifest::Manifest` etc. The most likely culprit is `vault.rs`. If `vault.rs` won't compile, leave it — Task 21 rewrites it. + +- [ ] **Step 3: Run new manifest tests** + +Run: `cargo test -p idfoto-core --lib manifest:: 2>&1 | tail -20` +Expected: 5 tests passed. + +The crate as a whole may not compile yet because `vault.rs` references the old `Manifest` from `entry.rs`. Run `cargo check -p idfoto-core 2>&1 | tail -20` to confirm the only errors are in `vault.rs`. We'll fix `vault.rs` in Task 21. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/manifest.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add typed-item Manifest with schema_version 2 + +ManifestEntry holds the per-item browse summary including derived +icon_hint (Login URL hostname) and attachment_summaries. Search matches +title or tag substring case-insensitively. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 19: Settings module + +**Files:** +- Create: `crates/idfoto-core/src/settings.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write `src/settings.rs`** + +```rust +//! Vault-level settings: trash retention, history retention, generator +//! defaults, attachment caps, autofill TOFU acks. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultSettings { + pub trash_retention: TrashRetention, + pub field_history_retention: HistoryRetention, + pub generator_defaults: GeneratorRequest, + pub attachment_caps: AttachmentCaps, + /// hostname → unix-seconds first-acked + #[serde(default)] + pub autofill_origin_acks: HashMap, +} + +impl Default for VaultSettings { + fn default() -> Self { + Self { + trash_retention: TrashRetention::Days(30), + field_history_retention: HistoryRetention::Forever, + generator_defaults: GeneratorRequest::default(), + attachment_caps: AttachmentCaps::default(), + autofill_origin_acks: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TrashRetention { + Days(u32), + Forever, +} + +impl TrashRetention { + pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool { + match self { + TrashRetention::Forever => false, + TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HistoryRetention { + LastN(u32), + Days(u32), + Forever, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GeneratorRequest { + Bip39 { + word_count: u32, + separator: String, + capitalization: Capitalization, + }, + Random { + length: u32, + classes: CharClasses, + symbol_charset: SymbolCharset, + }, +} + +impl Default for GeneratorRequest { + fn default() -> Self { + GeneratorRequest::Random { + length: 20, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capitalization { + Lower, + Upper, + FirstOfEach, + Title, + Mixed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CharClasses { + pub lower: bool, + pub upper: bool, + pub digits: bool, + pub symbols: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SymbolCharset { + SafeOnly, + Extended, + Custom(String), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct AttachmentCaps { + pub per_attachment_max_bytes: u64, + pub per_item_max_count: u32, + pub per_vault_soft_cap_bytes: u64, + pub per_vault_hard_cap_bytes: u64, +} + +impl Default for AttachmentCaps { + fn default() -> Self { + Self { + per_attachment_max_bytes: 10 * 1024 * 1024, + per_item_max_count: 20, + per_vault_soft_cap_bytes: 100 * 1024 * 1024, + per_vault_hard_cap_bytes: 500 * 1024 * 1024, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec() { + let s = VaultSettings::default(); + assert!(matches!(s.trash_retention, TrashRetention::Days(30))); + assert!(matches!(s.field_history_retention, HistoryRetention::Forever)); + assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024); + assert_eq!(s.attachment_caps.per_item_max_count, 20); + } + + #[test] + fn trash_retention_purges_after_days() { + let r = TrashRetention::Days(30); + let now = 1_000_000_000; + let recently_trashed = now - 29 * 86_400; + let long_trashed = now - 31 * 86_400; + assert!(!r.should_purge(recently_trashed, now)); + assert!(r.should_purge(long_trashed, now)); + } + + #[test] + fn trash_retention_forever_never_purges() { + let r = TrashRetention::Forever; + assert!(!r.should_purge(0, 1_000_000_000)); + } + + #[test] + fn settings_round_trip() { + let s = VaultSettings::default(); + let json = serde_json::to_string(&s).unwrap(); + let parsed: VaultSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.attachment_caps.per_attachment_max_bytes, + s.attachment_caps.per_attachment_max_bytes); + } + + #[test] + fn random_generator_default_is_20_safe() { + match VaultSettings::default().generator_defaults { + GeneratorRequest::Random { length, classes, symbol_charset } => { + assert_eq!(length, 20); + assert!(classes.lower && classes.upper && classes.digits && classes.symbols); + assert!(matches!(symbol_charset, SymbolCharset::SafeOnly)); + } + _ => panic!("expected Random default"), + } + } +} +``` + +- [ ] **Step 2: Wire into lib.rs** + +Add after `pub mod manifest;`: + +```rust +pub mod settings; +pub use settings::{ + AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention, + SymbolCharset, TrashRetention, VaultSettings, +}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p idfoto-core --lib settings:: 2>&1 | tail -20` +Expected: 5 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/settings.rs crates/idfoto-core/src/lib.rs +git commit -m "feat(core): add VaultSettings with retention + generator + caps + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 20: Generators — random password (unbiased) + +**Files:** +- Create: `crates/idfoto-core/src/generators.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Write `src/generators.rs` with random-password impl** + +```rust +//! Password and passphrase generators. CSPRNG-only; rejection-sampled to +//! eliminate modulo bias. Strength rating via zxcvbn. + +use rand::distributions::{Distribution, Uniform}; +use rand::rngs::OsRng; +use zeroize::Zeroizing; + +use crate::error::{IdfotoError, Result}; +use crate::settings::{CharClasses, GeneratorRequest, SymbolCharset}; + +const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+"; +const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?."; +const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz"; +const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS: &[u8] = b"0123456789"; + +pub fn generate_password(req: &GeneratorRequest) -> Result> { + match req { + GeneratorRequest::Random { length, classes, symbol_charset } => { + random_password(*length, classes, symbol_charset) + } + GeneratorRequest::Bip39 { .. } => Err(IdfotoError::Format( + "use generate_passphrase() for BIP39 requests".into(), + )), + } +} + +fn random_password( + length: u32, + classes: &CharClasses, + symbol_charset: &SymbolCharset, +) -> Result> { + if length == 0 || length > 128 { + return Err(IdfotoError::Format("length must be 1..=128".into())); + } + let mut charset: Vec = Vec::new(); + if classes.lower { charset.extend_from_slice(LOWER); } + if classes.upper { charset.extend_from_slice(UPPER); } + if classes.digits { charset.extend_from_slice(DIGITS); } + if classes.symbols { + let symbols: &[u8] = match symbol_charset { + SymbolCharset::SafeOnly => SAFE_SYMBOLS, + SymbolCharset::Extended => EXTENDED_SYMBOLS, + SymbolCharset::Custom(s) => s.as_bytes(), + }; + charset.extend_from_slice(symbols); + } + if charset.is_empty() { + return Err(IdfotoError::Format("at least one character class required".into())); + } + + let dist = Uniform::from(0..charset.len()); + let mut rng = OsRng; + let bytes: Vec = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect(); + Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_default_password_is_20_chars() { + let req = GeneratorRequest::default(); + let pw = generate_password(&req).unwrap(); + assert_eq!(pw.len(), 20); + } + + #[test] + fn rejects_zero_length() { + let req = GeneratorRequest::Random { + length: 0, + classes: CharClasses { lower: true, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + assert!(generate_password(&req).is_err()); + } + + #[test] + fn rejects_no_classes() { + let req = GeneratorRequest::Random { + length: 8, + classes: CharClasses { lower: false, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + assert!(generate_password(&req).is_err()); + } + + #[test] + fn lower_only_password_uses_lowercase() { + let req = GeneratorRequest::Random { + length: 100, + classes: CharClasses { lower: true, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + let pw = generate_password(&req).unwrap(); + assert!(pw.chars().all(|c| c.is_ascii_lowercase())); + } + + #[test] + fn safe_symbols_excludes_quotes_and_brackets() { + let req = GeneratorRequest::Random { + length: 1000, + classes: CharClasses { lower: false, upper: false, digits: false, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + }; + let pw = generate_password(&req).unwrap(); + for c in pw.chars() { + assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'), + "safe charset must not include {c}"); + } + } +} +``` + +- [ ] **Step 2: Wire into lib.rs** + +Add after `pub mod settings;`: + +```rust +pub mod generators; +pub use generators::generate_password; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p idfoto-core --lib generators:: 2>&1 | tail -20` +Expected: 5 tests passed. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/generators.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add CSPRNG random password generator with safe charset + +Uses rand::distributions::Uniform for unbiased sampling (audit H6). +Safe symbols = !@#$%^&*-_=+ (excludes characters that web forms +commonly reject). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 21: Generators — BIP39 passphrase + zxcvbn rating + +**Files:** +- Modify: `crates/idfoto-core/src/generators.rs` + +- [ ] **Step 1: Append BIP39 + zxcvbn impl** + +Append to `crates/idfoto-core/src/generators.rs`: + +```rust +use bip39::{Language, Mnemonic}; + +use crate::settings::Capitalization; + +pub fn generate_passphrase(req: &GeneratorRequest) -> Result> { + match req { + GeneratorRequest::Bip39 { word_count, separator, capitalization } => { + bip39_passphrase(*word_count, separator, *capitalization) + } + GeneratorRequest::Random { .. } => Err(IdfotoError::Format( + "use generate_password() for Random requests".into(), + )), + } +} + +fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result> { + if !matches!(word_count, 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12) { + return Err(IdfotoError::Format("word_count must be 3..=12".into())); + } + // bip39 generates from entropy — entropy bits = word_count * 11 - checksum + // For 12 words = 128 bits entropy; for fewer words we pick a multiple of 32 + let entropy_bytes = match word_count { + 3 => 4, // 32 bits + 4..=5 => 8, // 64 bits + 6 => 8, + 7..=8 => 16, // 128 bits + 9..=10 => 20, // 160 bits + 11..=12 => 24, // 192 bits — 12 words wants 16 bytes; we'll trim + _ => unreachable!(), + }; + let mut entropy = Zeroizing::new(vec![0u8; entropy_bytes]); + OsRng.fill_bytes(entropy.as_mut_slice()); + let m = Mnemonic::from_entropy_in(Language::English, &entropy) + .map_err(|e| IdfotoError::Format(format!("bip39: {e}")))?; + let words: Vec = m.word_iter().take(word_count as usize).map(|w| { + match cap { + Capitalization::Lower => w.to_ascii_lowercase(), + Capitalization::Upper => w.to_ascii_uppercase(), + Capitalization::FirstOfEach | Capitalization::Title => { + let mut chars = w.chars(); + chars.next().map(|c| c.to_ascii_uppercase().to_string()) + .unwrap_or_default() + chars.as_str() + } + Capitalization::Mixed => { + w.chars().enumerate().map(|(i, c)| { + if i % 2 == 0 { c.to_ascii_uppercase() } else { c } + }).collect() + } + } + }).collect(); + Ok(Zeroizing::new(words.join(separator))) +} + +/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses. +pub struct StrengthEstimate { + pub score: u8, + pub guesses_log10: f64, +} + +pub fn rate_passphrase(p: &str) -> StrengthEstimate { + let est = zxcvbn::zxcvbn(p, &[]); + StrengthEstimate { + score: est.score().into(), + guesses_log10: est.guesses_log10(), + } +} + +/// Strength gate at vault creation (audit H3): require score >= 3. +pub fn validate_passphrase_strength(p: &str) -> Result<()> { + let est = rate_passphrase(p); + if est.score < 3 { + return Err(IdfotoError::WeakPassphrase { score: est.score }); + } + Ok(()) +} + +#[cfg(test)] +mod bip39_tests { + use super::*; + + #[test] + fn bip39_default_is_5_space_separated_words() { + let req = GeneratorRequest::Bip39 { + word_count: 5, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + assert_eq!(pw.split(' ').count(), 5); + } + + #[test] + fn bip39_dash_separator() { + let req = GeneratorRequest::Bip39 { + word_count: 4, + separator: "-".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + assert_eq!(pw.split('-').count(), 4); + assert!(!pw.contains(' ')); + } + + #[test] + fn bip39_first_of_each_capitalizes() { + let req = GeneratorRequest::Bip39 { + word_count: 5, + separator: " ".into(), + capitalization: Capitalization::FirstOfEach, + }; + let pw = generate_passphrase(&req).unwrap(); + for word in pw.split(' ') { + let first = word.chars().next().unwrap(); + assert!(first.is_ascii_uppercase(), "word {word} should start uppercase"); + } + } + + #[test] + fn bip39_rejects_bad_word_count() { + let req = GeneratorRequest::Bip39 { + word_count: 2, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + assert!(generate_passphrase(&req).is_err()); + } + + #[test] + fn rate_passphrase_strong_one_passes_gate() { + // 5-word bip39 passphrase + let req = GeneratorRequest::Bip39 { + word_count: 6, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + assert!(validate_passphrase_strength(&pw).is_ok()); + } + + #[test] + fn rate_passphrase_weak_fails_gate() { + assert!(validate_passphrase_strength("password").is_err()); + assert!(validate_passphrase_strength("12345678").is_err()); + assert!(validate_passphrase_strength("hunter2").is_err()); + } +} +``` + +- [ ] **Step 2: Update lib.rs re-exports** + +Update the generators re-export: + +```rust +pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p idfoto-core --lib generators:: 2>&1 | tail -30` +Expected: all generator tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/generators.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add BIP39 passphrase generator + zxcvbn strength gate + +generate_passphrase honors word_count (3-12), separator, capitalization. +validate_passphrase_strength enforces zxcvbn score >= 3 (audit H3). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 22: Attachment encrypt/decrypt + +**Files:** +- Modify: `crates/idfoto-core/src/attachment.rs` + +- [ ] **Step 1: Append encrypt_attachment + decrypt_attachment** + +Append to `crates/idfoto-core/src/attachment.rs`: + +```rust +use zeroize::Zeroizing; + +use crate::crypto::{decrypt, encrypt}; +use crate::error::{IdfotoError, Result}; + +/// Encrypted attachment with the AID derived from plaintext content. +pub struct EncryptedAttachment { + pub id: AttachmentId, + pub bytes: Vec, +} + +pub fn encrypt_attachment( + plaintext: &[u8], + master_key: &Zeroizing<[u8; 32]>, + max_bytes: u64, +) -> Result { + if plaintext.len() as u64 > max_bytes { + return Err(IdfotoError::AttachmentTooLarge { + size: plaintext.len() as u64, + max: max_bytes, + }); + } + let id = AttachmentId::from_plaintext(plaintext); + let bytes = encrypt(plaintext, master_key)?; + Ok(EncryptedAttachment { id, bytes }) +} + +pub fn decrypt_attachment( + encrypted: &[u8], + master_key: &Zeroizing<[u8; 32]>, +) -> Result>> { + let plaintext = decrypt(encrypted, master_key)?; + Ok(Zeroizing::new(plaintext)) +} + +#[cfg(test)] +mod crypto_tests { + use super::*; + use zeroize::Zeroizing; + + fn key() -> Zeroizing<[u8; 32]> { + Zeroizing::new([0x42u8; 32]) + } + + #[test] + fn attachment_round_trip() { + let plaintext = b"the quick brown fox jumps over the lazy dog"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + let dec = decrypt_attachment(&enc.bytes, &key()).unwrap(); + assert_eq!(dec.as_slice(), plaintext); + } + + #[test] + fn attachment_id_matches_sha256() { + let plaintext = b"hello world"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext)); + } + + #[test] + fn oversize_attachment_rejected() { + let plaintext = vec![0u8; 11_000_000]; + let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024); + assert!(matches!(err, Err(IdfotoError::AttachmentTooLarge { .. }))); + } + + #[test] + fn wrong_key_fails_with_opaque_decrypt() { + let plaintext = b"x"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + let wrong = Zeroizing::new([0u8; 32]); + let err = decrypt_attachment(&enc.bytes, &wrong); + assert!(matches!(err, Err(IdfotoError::Decrypt))); + } +} +``` + +You'll need to verify the `crypto::encrypt` signature accepts `&Zeroizing<[u8; 32]>`. If it currently takes `&[u8; 32]`, adapt by passing `&master_key` (Deref coerces). If the signature is something else (e.g. takes `&[u8]`), match what's there. + +- [ ] **Step 2: Wire into lib.rs** + +Update the attachment re-export: + +```rust +pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p idfoto-core --lib attachment:: 2>&1 | tail -20` +Expected: 6 tests passed (2 original + 4 new). + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/attachment.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): add encrypt_attachment + decrypt_attachment + +AttachmentId is derived from sha256(plaintext) so identical content +deduplicates naturally. Size cap enforced at encrypt time, returning +IdfotoError::AttachmentTooLarge. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 23: Vault helpers — encrypt/decrypt Item, Manifest, VaultSettings + +**Files:** +- Modify: `crates/idfoto-core/src/vault.rs` (full rewrite) + +- [ ] **Step 1: Replace `crates/idfoto-core/src/vault.rs`** + +```rust +//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item +//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse. +//! +//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old +//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in +//! Plan 1B switches to the new helpers. + +use zeroize::Zeroizing; + +use crate::crypto::{decrypt, encrypt}; +use crate::error::Result; +use crate::item::Item; +use crate::manifest::Manifest; +use crate::settings::VaultSettings; + +pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result> { + let json = serde_json::to_vec(item)?; + let plaintext = Zeroizing::new(json); + encrypt(&plaintext, master_key) +} + +pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(encrypted, master_key)?; + let plaintext = Zeroizing::new(plaintext); + let item: Item = serde_json::from_slice(&plaintext)?; + Ok(item) +} + +pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result> { + let json = serde_json::to_vec(manifest)?; + let plaintext = Zeroizing::new(json); + encrypt(&plaintext, master_key) +} + +pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(encrypted, master_key)?; + let plaintext = Zeroizing::new(plaintext); + let manifest: Manifest = serde_json::from_slice(&plaintext)?; + Ok(manifest) +} + +pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result> { + let json = serde_json::to_vec(settings)?; + let plaintext = Zeroizing::new(json); + encrypt(&plaintext, master_key) +} + +pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(encrypted, master_key)?; + let plaintext = Zeroizing::new(plaintext); + let settings: VaultSettings = serde_json::from_slice(&plaintext)?; + Ok(settings) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::item_types::{ItemCore, SecureNoteCore}; + + fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) } + + #[test] + fn item_round_trip() { + let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new("hello".into()), + })); + let bytes = encrypt_item(&item, &key()).unwrap(); + let decoded = decrypt_item(&bytes, &key()).unwrap(); + assert_eq!(decoded.title, "note"); + } + + #[test] + fn manifest_round_trip() { + let mut m = Manifest::new(); + let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let bytes = encrypt_manifest(&m, &key()).unwrap(); + let decoded = decrypt_manifest(&bytes, &key()).unwrap(); + assert_eq!(decoded.items.len(), 1); + } + + #[test] + fn settings_round_trip() { + let s = VaultSettings::default(); + let bytes = encrypt_settings(&s, &key()).unwrap(); + let decoded = decrypt_settings(&bytes, &key()).unwrap(); + assert_eq!(decoded.attachment_caps.per_attachment_max_bytes, + s.attachment_caps.per_attachment_max_bytes); + } +} +``` + +- [ ] **Step 2: Update lib.rs vault re-export** + +Replace the existing `pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};` line with: + +```rust +pub use vault::{ + decrypt_item, decrypt_manifest, decrypt_settings, + encrypt_item, encrypt_manifest, encrypt_settings, +}; +``` + +The `Manifest` re-export is now unambiguous because `entry.rs::Manifest` was already removed from the re-export in Task 18. + +- [ ] **Step 3: Run vault tests** + +Run: `cargo test -p idfoto-core --lib vault:: 2>&1 | tail -20` +Expected: 3 tests passed. + +If `cargo build -p idfoto-core` still fails, it's because something elsewhere in the crate (likely the integration test) references `encrypt_entry` or the old types. Search and remove or comment out — Task 26 rewrites the integration test: + +```bash +grep -rn 'encrypt_entry\|decrypt_entry\|generate_entry_id' crates/idfoto-core/ +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/vault.rs crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +feat(core): rewrite vault.rs for typed items + +encrypt_item / decrypt_item / encrypt_manifest / decrypt_manifest / +encrypt_settings / decrypt_settings. All plaintext flows through +Zeroizing so JSON buffers are wiped on drop. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 24: Field history retention pruning + +**Files:** +- Modify: `crates/idfoto-core/src/item.rs` + +- [ ] **Step 1: Write failing test** + +Append to the `tests` module in `crates/idfoto-core/src/item.rs`: + +```rust +#[test] +fn prune_history_keeps_last_n() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + for i in 1..=5 { + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + } + assert_eq!(item.field_history[&fid].len(), 5); + + item.prune_history(&HistoryRetention::LastN(3), 0); + assert_eq!(item.field_history[&fid].len(), 3); + // Keeps the MOST RECENT 3 + assert_eq!(item.field_history[&fid][0].value.as_str(), "v2"); +} + +#[test] +fn prune_history_drops_old_entries_by_days() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + let now = 1_000_000_000; + item.field_history.insert(fid.clone(), vec![ + FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 }, + FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 1 * 86_400 }, + ]); + + item.prune_history(&HistoryRetention::Days(30), now); + assert_eq!(item.field_history[&fid].len(), 1); + assert_eq!(item.field_history[&fid][0].value.as_str(), "recent"); +} + +#[test] +fn prune_history_forever_keeps_all() { + use crate::settings::HistoryRetention; + + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("x".into(), core); + item.field_history.insert(FieldId::new(), vec![ + FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 }, + FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 }, + ]); + item.prune_history(&HistoryRetention::Forever, 1_000_000_000); + assert_eq!(item.field_history.values().next().unwrap().len(), 2); +} +``` + +- [ ] **Step 2: Run tests, expect fail** + +Run: `cargo test -p idfoto-core --lib item::tests::prune_history 2>&1 | tail -10` +Expected: compile error — `prune_history` not defined. + +- [ ] **Step 3: Implement `prune_history` on Item** + +Add to the `impl Item` block in `crates/idfoto-core/src/item.rs`: + +```rust +pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) { + use crate::settings::HistoryRetention; + for history in self.field_history.values_mut() { + match retention { + HistoryRetention::Forever => {} + HistoryRetention::LastN(n) => { + let n = *n as usize; + if history.len() > n { + let drop_count = history.len() - n; + history.drain(..drop_count); + } + } + HistoryRetention::Days(d) => { + let cutoff = now - (*d as i64) * 86_400; + history.retain(|e| e.replaced_at > cutoff); + } + } + } +} +``` + +- [ ] **Step 4: Run tests, expect pass** + +Run: `cargo test -p idfoto-core --lib item::tests::prune_history 2>&1 | tail -10` +Expected: 3 tests passed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/idfoto-core/src/item.rs +git commit -m "$(cat <<'EOF' +feat(core): add Item::prune_history honoring retention policy + +Forever, LastN, and Days policies all covered. Tests verify drop order +(keeps newest), days cutoff, and forever-no-op semantics. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 25: Delete entry.rs and finalize lib.rs + +**Files:** +- Delete: `crates/idfoto-core/src/entry.rs` +- Modify: `crates/idfoto-core/src/lib.rs` + +- [ ] **Step 1: Delete entry.rs** + +```bash +git rm crates/idfoto-core/src/entry.rs +``` + +- [ ] **Step 2: Remove all `entry` references from lib.rs** + +Open `crates/idfoto-core/src/lib.rs` and: + +1. Remove the line `pub mod entry;`. +2. Remove the line `pub use entry::{generate_entry_id, Entry};` (and any related re-exports). +3. Update the doc comment header — the existing module comment lists `entry`, `vault`, etc. Rewrite the module list to match the new layout. Recommended replacement: + +```rust +//! ## Modules +//! +//! - [`error`] — The unified error type ([`IdfotoError`]). +//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and +//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02. +//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`. +//! - [`time`] — unix-seconds + `MonthYear` for card expiries. +//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the +//! `ItemCore`/`ItemType` enums. +//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`, +//! `FieldHistoryEntry`. +//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers. +//! - [`manifest`] — Browse-without-decrypt index (schema_version 2). +//! - [`settings`] — Vault-level retention, generator defaults, attachment caps. +//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn +//! strength gate. +//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings). +//! - [`imgsecret`] — DCT-based steganography for the second auth factor. +``` + +The full final lib.rs should look like (re-export section): + +```rust +pub mod error; +pub use error::{IdfotoError, Result}; + +pub mod crypto; +pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; + +pub mod ids; +pub use ids::{AttachmentId, FieldId, ItemId}; + +pub mod time; +pub use time::{now_unix, MonthYear}; + +pub mod item_types; +pub use item_types::{ItemCore, ItemType}; + +pub mod item; +pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; + +pub mod attachment; +pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment}; + +pub mod manifest; +pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION}; + +pub mod settings; +pub use settings::{ + AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention, + SymbolCharset, TrashRetention, VaultSettings, +}; + +pub mod generators; +pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate}; + +pub mod vault; +pub use vault::{ + decrypt_item, decrypt_manifest, decrypt_settings, + encrypt_item, encrypt_manifest, encrypt_settings, +}; + +pub mod imgsecret; +``` + +- [ ] **Step 3: Verify the crate builds** + +Run: `cargo build -p idfoto-core 2>&1 | tail -20` +Expected: clean build with no warnings about `entry`. (`cargo build` of the whole workspace will still fail because `idfoto-cli` references the old types — that's expected and is fixed in Plan 1B.) + +Run: `cargo test -p idfoto-core --lib 2>&1 | tail -20` +Expected: all unit tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/idfoto-core/src/lib.rs +git commit -m "$(cat <<'EOF' +refactor(core): delete entry.rs; finalize typed-item lib.rs re-exports + +The old Entry/ManifestEntry/Manifest types are gone. CLI/extension +references break and will be fixed by Plans 1B and 1C respectively. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 26: Integration test — full typed-item workflow + +**Files:** +- Modify: `crates/idfoto-core/tests/integration.rs` (full rewrite) + +- [ ] **Step 1: Rewrite `crates/idfoto-core/tests/integration.rs`** + +Replace the entire contents of `crates/idfoto-core/tests/integration.rs` with: + +```rust +//! End-to-end integration tests for the typed-item core. + +use idfoto_core::{ + crypto::KdfParams, + derive_master_key, encrypt_item, decrypt_item, + encrypt_manifest, decrypt_manifest, + encrypt_settings, decrypt_settings, + Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings, +}; +use idfoto_core::item_types::{LoginCore, SecureNoteCore}; +use url::Url; +use zeroize::Zeroizing; + +fn fast_params() -> KdfParams { + KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } +} + +#[test] +fn full_workflow_login_and_note() { + let salt = [0xAAu8; 32]; + let img = [0xBBu8; 32]; + let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap(); + + let mut manifest = Manifest::new(); + let settings = VaultSettings::default(); + + // Add a Login + let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com").unwrap()), + totp: None, + })); + manifest.upsert(&login); + let login_blob = encrypt_item(&login, &key).unwrap(); + + // Add a SecureNote + let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new("recovery codes go here".into()), + })); + manifest.upsert(¬e); + let note_blob = encrypt_item(¬e, &key).unwrap(); + + // Encrypt manifest + settings + let manifest_blob = encrypt_manifest(&manifest, &key).unwrap(); + let settings_blob = encrypt_settings(&settings, &key).unwrap(); + + // Decrypt + verify + let m = decrypt_manifest(&manifest_blob, &key).unwrap(); + assert_eq!(m.items.len(), 2); + + let l: Item = decrypt_item(&login_blob, &key).unwrap(); + let n: Item = decrypt_item(¬e_blob, &key).unwrap(); + let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap(); + + assert_eq!(l.title, "GitHub"); + assert_eq!(n.title, "recovery"); + assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024); +} + +#[test] +fn two_factor_independence() { + // Same passphrase, different image_secret → different keys. + let salt = [0u8; 32]; + let img_a = [0x01u8; 32]; + let img_b = [0x02u8; 32]; + + let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap(); + let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap(); + assert_ne!(*key_a, *key_b); + + // Different passphrase, same image_secret → different keys. + let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap(); + assert_ne!(*key_a, *key_c); +} + +#[test] +fn field_history_persists_through_round_trip() { + let salt = [0u8; 32]; + let img = [0u8; 32]; + let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap(); + + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap(); + + let blob = encrypt_item(&item, &key).unwrap(); + let decoded = decrypt_item(&blob, &key).unwrap(); + let hist = decoded.field_history.get(&fid).unwrap(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].value.as_str(), "v0"); + assert_eq!(hist[1].value.as_str(), "v1"); +} + +#[test] +fn wrong_key_fails_with_opaque_decrypt() { + use idfoto_core::IdfotoError; + + let salt = [0u8; 32]; + let img = [0u8; 32]; + let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap(); + let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap(); + + let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default())); + let blob = encrypt_item(&item, &right).unwrap(); + let err = decrypt_item(&blob, &wrong); + assert!(matches!(err, Err(IdfotoError::Decrypt))); +} +``` + +- [ ] **Step 2: Run integration tests** + +Run: `cargo test -p idfoto-core --test integration 2>&1 | tail -20` +Expected: 4 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/tests/integration.rs +git commit -m "$(cat <<'EOF' +test(core): rewrite integration test for typed items + +- full_workflow_login_and_note: round-trips Login + SecureNote + Manifest + Settings +- two_factor_independence: confirms image_secret + passphrase combine into the master key +- field_history_persists_through_round_trip: history survives encrypt/decrypt +- wrong_key_fails_with_opaque_decrypt: opaque error per audit M4 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 27: Integration test — attachments + +**Files:** +- Create: `crates/idfoto-core/tests/attachments.rs` + +- [ ] **Step 1: Write `tests/attachments.rs`** + +```rust +//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement. + +use idfoto_core::{ + AttachmentId, IdfotoError, + crypto::KdfParams, + decrypt_attachment, derive_master_key, encrypt_attachment, +}; +use zeroize::Zeroizing; + +fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } } + +fn make_key() -> Zeroizing<[u8; 32]> { + derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap() +} + +#[test] +fn attachment_round_trip_5kb() { + let plaintext: Vec = (0..5000u32).map(|i| (i & 0xff) as u8).collect(); + let key = make_key(); + let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap(); + assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext)); + + let dec = decrypt_attachment(&enc.bytes, &key).unwrap(); + assert_eq!(&*dec, &plaintext); +} + +#[test] +fn identical_plaintexts_yield_identical_aids() { + let plaintext = b"hello world"; + let key = make_key(); + let a = encrypt_attachment(plaintext, &key, 1024).unwrap(); + let b = encrypt_attachment(plaintext, &key, 1024).unwrap(); + assert_eq!(a.id, b.id); + // (Bytes will differ because nonce is random per-encryption — that's expected.) +} + +#[test] +fn cap_enforcement_at_exact_max() { + let plaintext = vec![0u8; 1024]; + let key = make_key(); + // Exactly at max — should pass + let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap(); + // One byte over — should fail + let err = encrypt_attachment(&plaintext, &key, 1023); + match err { + Err(IdfotoError::AttachmentTooLarge { size, max }) => { + assert_eq!(size, 1024); + assert_eq!(max, 1023); + } + other => panic!("expected AttachmentTooLarge, got {other:?}"), + } +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test -p idfoto-core --test attachments 2>&1 | tail -20` +Expected: 3 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/tests/attachments.rs +git commit -m "test(core): integration tests for attachments (round-trip, AID, caps) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 28: Integration test — generator unbiased + zxcvbn gate + +**Files:** +- Create: `crates/idfoto-core/tests/generators.rs` + +- [ ] **Step 1: Write `tests/generators.rs`** + +```rust +//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity, +//! zxcvbn strength gate. + +use idfoto_core::{ + Capitalization, CharClasses, GeneratorRequest, SymbolCharset, + generate_passphrase, generate_password, validate_passphrase_strength, +}; + +#[test] +fn random_password_class_balance_is_reasonable() { + // Sample 10000 chars; each class should appear roughly proportionally. + let req = GeneratorRequest::Random { + length: 10000, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + }; + let pw = generate_password(&req).unwrap(); + + let lower = pw.chars().filter(|c| c.is_ascii_lowercase()).count(); + let upper = pw.chars().filter(|c| c.is_ascii_uppercase()).count(); + let digits = pw.chars().filter(|c| c.is_ascii_digit()).count(); + let symbols = pw.len() - lower - upper - digits; + + // Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74 + // Expect proportions roughly: 26/74 ≈ 35%, 10/74 ≈ 14%, 12/74 ≈ 16% + // Allow ±5pp slop. + let total = pw.len() as f64; + let assert_pct = |actual: usize, expected_pct: f64| { + let pct = (actual as f64) / total * 100.0; + assert!((pct - expected_pct).abs() < 5.0, + "actual {pct:.1}% vs expected {expected_pct:.1}%"); + }; + assert_pct(lower, 26.0 / 74.0 * 100.0); + assert_pct(upper, 26.0 / 74.0 * 100.0); + assert_pct(digits, 10.0 / 74.0 * 100.0); + assert_pct(symbols, 12.0 / 74.0 * 100.0); +} + +#[test] +fn bip39_5_word_passphrase_passes_zxcvbn_gate() { + let req = GeneratorRequest::Bip39 { + word_count: 5, + separator: " ".into(), + capitalization: Capitalization::Lower, + }; + let pw = generate_passphrase(&req).unwrap(); + validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3"); +} + +#[test] +fn common_weak_passphrases_fail_gate() { + for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] { + assert!(validate_passphrase_strength(weak).is_err(), + "expected '{weak}' to fail gate"); + } +} + +#[test] +fn random_passwords_are_unique_across_calls() { + let req = GeneratorRequest::default(); + let mut seen = std::collections::HashSet::new(); + for _ in 0..1000 { + let pw = generate_password(&req).unwrap(); + assert!(seen.insert(pw.as_str().to_owned())); + } +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test -p idfoto-core --test generators 2>&1 | tail -20` +Expected: 4 tests passed. (Class-balance test has some statistical slop — re-run if it flakes; 5pp tolerance is generous.) + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/tests/generators.rs +git commit -m "test(core): integration tests for generators (balance, BIP39, gate) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 29: Integration test — format v2 + length-prefix KDF + +**Files:** +- Create: `crates/idfoto-core/tests/format_v2.rs` + +- [ ] **Step 1: Write `tests/format_v2.rs`** + +```rust +//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with +//! UnsupportedFormatVersion, length-prefix construction guarantees domain +//! separation. + +use idfoto_core::{ + IdfotoError, + crypto::{KdfParams, VERSION_BYTE}, + decrypt, derive_master_key, encrypt, +}; +use zeroize::Zeroizing; + +fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } } + +#[test] +fn version_byte_is_2() { + assert_eq!(VERSION_BYTE, 0x02); +} + +#[test] +fn fresh_ciphertext_starts_with_0x02() { + let key = Zeroizing::new([0u8; 32]); + let ct = encrypt(b"hello", &key).unwrap(); + assert_eq!(ct[0], 0x02); +} + +#[test] +fn v1_blob_is_rejected_with_unsupported_format_version() { + // v1 layout: [0x01][24 nonce bytes][16 tag bytes] + let mut blob = vec![0x01u8]; + blob.extend_from_slice(&[0u8; 24 + 16]); + let key = Zeroizing::new([0u8; 32]); + let err = decrypt(&blob, &key); + match err { + Err(IdfotoError::UnsupportedFormatVersion { found, expected }) => { + assert_eq!(found, 0x01); + assert_eq!(expected, 0x02); + } + other => panic!("expected UnsupportedFormatVersion, got {other:?}"), + } +} + +#[test] +fn length_prefix_distinguishes_concat_collisions() { + let salt = [0u8; 32]; + let img = [0x44u8; 32]; + let p1 = b"abc"; + let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...) + // could be made to collide. With length-prefix they cannot. + let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap(); + let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap(); + assert_ne!(*k1, *k2); +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test -p idfoto-core --test format_v2 2>&1 | tail -20` +Expected: 4 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/tests/format_v2.rs +git commit -m "$(cat <<'EOF' +test(core): integration tests for format v2 invariants + +VERSION_BYTE = 0x02; v1 blobs rejected with UnsupportedFormatVersion; +length-prefix Argon2 input distinguishes collision-engineerable pairs +(audit H1 regression test). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 30: Field history retention integration test + +**Files:** +- Create: `crates/idfoto-core/tests/field_history.rs` + +- [ ] **Step 1: Write `tests/field_history.rs`** + +```rust +//! Field history end-to-end: capture on update, prune by retention policy, +//! survive encrypt/decrypt round-trip. + +use idfoto_core::{ + Field, FieldValue, HistoryRetention, Item, ItemCore, Section, + crypto::KdfParams, + derive_master_key, decrypt_item, encrypt_item, +}; +use idfoto_core::item_types::LoginCore; +use zeroize::Zeroizing; + +fn key() -> Zeroizing<[u8; 32]> { + derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap() +} + +#[test] +fn password_field_history_captured_on_update() { + let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap(); + + let hist = item.field_history.get(&fid).expect("history exists"); + assert_eq!(hist.len(), 3); + assert_eq!(hist[0].value.as_str(), "v0"); + assert_eq!(hist[2].value.as_str(), "v2"); +} + +#[test] +fn prune_last_n_keeps_most_recent() { + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + for i in 1..=10 { + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}").into()))).unwrap(); + } + item.prune_history(&HistoryRetention::LastN(3), 0); + let hist = &item.field_history[&fid]; + assert_eq!(hist.len(), 3); + // Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured) + assert!(hist.last().unwrap().value.as_str().starts_with('v')); +} + +#[test] +fn history_survives_encrypt_decrypt() { + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + + let blob = encrypt_item(&item, &key()).unwrap(); + let decoded = decrypt_item(&blob, &key()).unwrap(); + + let hist = decoded.field_history.get(&fid).expect("history survived"); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].value.as_str(), "v0"); +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test -p idfoto-core --test field_history 2>&1 | tail -20` +Expected: 3 tests passed. + +- [ ] **Step 3: Commit** + +```bash +git add crates/idfoto-core/tests/field_history.rs +git commit -m "test(core): field history integration (capture, prune, round-trip) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 31: Final pass — full test run + doc comments + +**Files:** +- N/A (verification only) + +- [ ] **Step 1: Run the full core test suite** + +Run: `cargo test -p idfoto-core 2>&1 | tail -40` +Expected: ALL tests pass — unit tests in every module + integration tests in `tests/integration.rs`, `tests/attachments.rs`, `tests/generators.rs`, `tests/format_v2.rs`, `tests/field_history.rs`. + +If anything fails, investigate, fix, re-run, and add a separate commit for each fix. + +- [ ] **Step 2: Run `cargo doc -p idfoto-core --no-deps`** + +Run: `cargo doc -p idfoto-core --no-deps 2>&1 | tail -10` +Expected: clean docs build (no `missing_docs` errors are required since we don't enforce them, but warnings about broken intra-doc links should be fixed). + +- [ ] **Step 3: Run `cargo clippy -p idfoto-core --all-targets`** + +Run: `cargo clippy -p idfoto-core --all-targets 2>&1 | tail -20` +Expected: clean (or pre-existing warnings only — no new clippy lints introduced by this plan). + +- [ ] **Step 4: Verify the workspace builds for at least the core crate** + +Run: `cargo build -p idfoto-core --release 2>&1 | tail -10` +Expected: clean release build. + +- [ ] **Step 5: Tag a milestone** + +```bash +git tag plan-1a-rust-core-complete +git log --oneline -1 +``` + +This gives Plan 1B a clear starting point. + +--- + +## Acceptance Criteria + +After Task 31: + +1. `cargo test -p idfoto-core` passes all unit + integration tests (no failures, no ignored tests). +2. `cargo build -p idfoto-core --release` succeeds clean. +3. `cargo clippy -p idfoto-core --all-targets` has no new lint regressions. +4. `crates/idfoto-core/src/entry.rs` no longer exists. +5. `lib.rs` re-exports the new typed-item API exactly as listed in Task 25. +6. The audit fixes baked into this plan are demonstrable: + - `derive_master_key` returns `Zeroizing<[u8; 32]>` (H2) + - Argon2 input is length-prefixed (H1, regression test in `tests/format_v2.rs`) + - Passphrase is NFC-normalized before hashing (H1, regression test in `crypto::tests::nfc_normalization_collapses_unicode_forms`) + - `IdfotoError::Decrypt` has no internal-detail string (M4) + - Item / Field / Attachment IDs are 16 hex chars (M8) + - `generate_password` uses `Uniform` (no modulo bias, H6 — H5 is in Plan 1B's WASM crate) + - `validate_passphrase_strength` enforces zxcvbn score ≥ 3 (H3) +7. The `idfoto-cli` crate is **expected to fail compilation** at the end of this plan. Plan 1B fixes it. + +## Hand-off to Plan 1B + +Plan 1B (CLI + WASM bridge) starts from the `plan-1a-rust-core-complete` tag and: + +- Rewrites `crates/idfoto-cli/src/main.rs` to use the new typed-item API. +- Adds the `idfoto-wasm` session-handle bridge. +- Implements audit fixes outside the data-model surface: H4 (hardened git shell-out), H5 (CSPRNG in WASM), H6 (the CLI side, already covered partially by Task 20), H7 (rpassword 7.x), M3 (imgsecret MAX_DIMENSION), M6 (clipboard zeroize), M7 (no plaintext to stdout), M11 (ISO-8601 timestamps), L8 (vault dir detection). +- Writes integration tests for the CLI commands. + +Plan 1C (extension) starts from Plan 1B's completion tag and tackles the browser-side rewrite.