docs: add security audit + typed-item data model design

Adds the Phase 1 design spec for the polymorphic typed-item rewrite (Login,
SecureNote, Identity, Card, Key, Document, TOTP — with sections, custom
fields, attachments, password history, and the security architecture from
the audit baked in from day one). Also adds the initial full-codebase
security audit that informs both Phase 0 remediation and Phase 1 design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 01:35:49 -04:00
parent 2524270524
commit cc7247e7f6
2 changed files with 1292 additions and 0 deletions

View File

@@ -0,0 +1,920 @@
# idfoto — Typed Item Data Model Design
Foundational data-model rewrite for idfoto. Replaces the single `Entry` type with a polymorphic typed-item system supporting Login, SecureNote, Identity, Card, Key, Document, and TOTP — with sections, custom fields, attachments, password history, soft-delete, and the security architecture needed to support 1Password-style daily-driver UX.
This is **Phase 1** of the broader 1Password-parity roadmap. Phase 0 (audit remediation) is the precursor implementation pass; Phase 2+ (admin portal, importers, Watchtower checks, etc.) build on top of this model.
## Scope
In:
- New typed-item Rust data model in `idfoto-core` (replaces `Entry`)
- New on-disk repo layout (items + attachments split, settings file, format version 2)
- Cryptographic envelope updates (length-prefixed Argon2 inputs, Zeroize discipline, opaque session-handle WASM bridge)
- Security architecture for the extension boundary (split message router, origin-checked autofill, closed Shadow DOM rendering, hardened CLI git shell-out)
- WASM API surface for typed items
- Manifest schema supporting browse-without-decrypt
- Vault settings (`settings.enc`) for retention policies, generator defaults, attachment caps, autofill TOFU acks
- BIP39 + random password generators with safe-symbol charset
- Field-level history tracking for sensitive kinds (Password / Concealed / Totp)
Out (deferred to later phases):
- Admin portal (Phase 2)
- Bulk import (Phase 2)
- Watchtower-style checks — HIBP, weak/reused detection (Phase 4)
- TOTP-in-list always-visible display (Phase 5)
- SSH agent, mobile, multi-vault, sharing (Phase 6+)
- Field-level merge of conflicting item edits (post-MVP — MVP prompts user to pick a side)
- Backward compatibility with the v1 vault format (clean break — no users today)
## Roadmap Context
| Phase | Deliverable |
|---|---|
| **Phase 0** | Security remediation per `docs/superpowers/audits/2026-04-18-initial-security-audit.md` (C1C4, H1H8) |
| **Phase 1** | **This spec — typed item data model** |
| Phase 2 | Admin portal scaffold + bulk import + LastPass adapter |
| Phase 3 | 1Password / Chrome / Bitdefender import adapters |
| Phase 4 | Watchtower-style checks (HIBP, weak/reused, 2FA-available) |
| Phase 5 | Daily-driver polish (TOTP-in-list, fuzzy search, autofill polish, quick-fill shortcut) |
| Phase 6+ | SSH agent, mobile (Tauri), multi-vault, sharing |
Phase 0 lands first to remove the audit's release-blocker bugs from the surfaces this spec touches. Phase 1 then builds on the fixed foundation; the security architecture in this spec is the design counterpart of Phase 0's tactical fixes.
## Design Decisions
Captured during brainstorming so the rationale is preserved:
| Question | Decision | Why |
|---|---|---|
| Type granularity | **B**: small structural set (~7 types) + extensibility for future types | 1P's ~20 types are mostly labeling differences; structural set + custom fields covers ~90% of UX with ~30% of the code |
| Field structure | **B+C blend**: typed core fields per variant + sections of custom fields + attachments | Strong typing for predictable fields (autofill, importers benefit) + 1P-style sections for everything else |
| Type list | 7 types: Login, SecureNote, Identity, Card, Key, Document, **TOTP** | TOTP gets *both* Login.totp_secret AND a standalone type (Steam Guard, 2FA-only accounts) |
| Tags vs groups | **Both** — keep both | Tags are flat/cross-cutting (`#work`); groups are hierarchical buckets (`Banking`). Different UX purposes |
| Soft-delete | **Yes**, with configurable retention | Cheap insurance; default 30 days, settable to N days or `forever` |
| Password history | **Yes**, field-kind-driven | Generic over Password / Concealed / Totp kinds — covers Login, Card, Key, TOTP, custom Concealed for free |
| Storage layout | **C**: items + attachments split | Attachments must be separate so 5MB documents don't bloat every metadata sync |
| Serialization | **JSON** then AEAD | KISS — items are tiny, encrypted blob obscures debuggability concerns, max tooling support |
| Extensibility architecture | **A**: plain Rust enum, per-type modules | KISS — Rust's enum + exhaustiveness check IS the extension mechanism; trait+registry's flexibility doesn't pay off until many types |
| Migration from v1 | **None** — clean break | No users today; freely fold all audit fixes into the initial format |
| Strength meter | **zxcvbn**, color-coded slider | ~200KB WASM cost; same library powers Phase 4 Watchtower |
| Generators | **BIP39** (5-word default, space-separator default) + **Random** (20 chars, lower+upper+digits+SAFE_symbols default) | SAFE_symbols = `!@#$%^&*-_=+`; excludes `'"`,;:{}[]<>()|\\/?` that web forms commonly reject |
| Custom field IDs | **Stable `field_id` separate from label** | Renaming a field preserves its history |
| Per-attachment cap | **10MB**, 20-per-item, 500MB-per-vault soft cap | GitHub hard-rejects at 100MB per file; Gitea typically 50MB; comfortable headroom |
| Field kinds | 11 kinds: Text, Multiline, Password, Concealed, Url, Email, Phone, Date, MonthYear, Totp, Reference | `Address` modeled as Multiline; `Number` as Text; `SSHKey` as Concealed or attachment |
| Audit fixes baked in | **All of C1C4, H1H8 designed-in from day one** | No technical debt added on top of a known-broken foundation |
## Architecture Overview
```
┌────────────────────────────────────────────────────────────────────┐
│ idfoto-core (Rust) │
│ - Item, ItemCore (7 variants), Field, Section, Attachment │
│ - Manifest, VaultSettings │
│ - crypto: KDF (length-prefixed), AEAD, Zeroize discipline │
│ - generators: bip39, csprng-random │
│ - serialization: serde-json → AEAD │
└──────────┬───────────────────────────────┬─────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────────┐
│ idfoto-cli (Rust) │ │ idfoto-wasm (Rust → WASM) │
│ - clap commands │ │ - opaque session handles │
│ - hardened git │ │ - typed-item API surface │
│ - rpassword 7.x │ │ - master_key never returned to │
│ - clipboard + │ │ JS │
│ Zeroize │ └──────────┬───────────────────────┘
└──────────────────────┘ │
┌──────────────────────────────────────┐
│ Browser Extension (TypeScript) │
│ - Service worker: split router │
│ (popup-only / content-callable) │
│ - Content scripts: closed Shadow │
│ DOM, textContent only │
│ - Popup UI: typed-item forms │
│ - Setup wizard: not in WAR │
└──────────────────────────────────────┘
```
## Data Model (Rust core)
### Item envelope (universal across all 7 types)
```rust
pub struct Item {
pub id: ItemId, // 16-char hex (audit M8)
pub title: String,
pub r#type: ItemType,
pub tags: Vec<String>,
pub favorite: bool,
pub group: Option<String>,
pub notes: Option<String>,
pub created: i64, // unix-seconds
pub modified: i64,
pub trashed_at: Option<i64>, // soft-delete
pub core: ItemCore, // typed per variant
pub sections: Vec<Section>,
pub attachments: Vec<AttachmentRef>,
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
}
pub type ItemId = String; // 16-char hex
pub type FieldId = String; // 16-char hex
pub type AttachmentId = String; // 16-char hex (sha256 of plaintext, truncated)
```
### Type variants
```rust
pub enum ItemType { Login, SecureNote, Identity, Card, Key, Document, Totp }
pub enum ItemCore {
Login(LoginCore),
SecureNote(SecureNoteCore),
Identity(IdentityCore),
Card(CardCore),
Key(KeyCore),
Document(DocumentCore),
Totp(TotpCore),
}
```
Each variant struct lives in `crates/idfoto-core/src/item_types/<type>.rs`. Compiler enforces exhaustiveness across the codebase — adding a new variant later means: create the file, add the enum variant, fix the (typically ~5) match-arm sites the compiler points at, register the UI form. No reflection, no registry, no runtime dispatch.
### Per-type cores
```rust
pub struct LoginCore {
pub username: Option<String>,
pub password: Option<Zeroizing<String>>,
pub url: Option<Url>,
pub totp: Option<TotpConfig>,
}
pub struct SecureNoteCore {
pub body: Zeroizing<String>, // Multiline
}
pub struct IdentityCore {
pub full_name: Option<String>,
pub address: Option<String>, // Multiline
pub phone: Option<String>,
pub email: Option<String>,
pub date_of_birth: Option<NaiveDate>,
}
pub struct CardCore {
pub number: Option<Zeroizing<String>>,
pub holder: Option<String>,
pub expiry: Option<MonthYear>,
pub cvv: Option<Zeroizing<String>>,
pub pin: Option<Zeroizing<String>>,
pub kind: CardKind, // Credit | Debit | Gift | Loyalty | Other
}
pub struct KeyCore {
pub key_material: Zeroizing<String>,
pub label: Option<String>,
pub public_key: Option<String>,
pub algorithm: Option<String>, // free-form: "ed25519", "rsa-4096", etc.
}
pub struct DocumentCore {
pub filename: String,
pub mime_type: String,
pub primary_attachment: AttachmentId, // every Document has one main blob
}
pub struct TotpCore {
pub config: TotpConfig,
pub issuer: Option<String>,
pub label: Option<String>,
}
pub struct TotpConfig {
pub secret: Zeroizing<Vec<u8>>, // raw bytes (not base32)
pub algorithm: TotpAlgorithm, // Sha1 | Sha256 | Sha512
pub digits: u8, // 6, 7, or 8
pub period_seconds: u32, // default 30
pub kind: TotpKind, // Totp | Hotp(counter) | Steam
}
```
### Sections + custom fields
```rust
pub struct Section {
pub name: Option<String>, // None = anonymous section
pub fields: Vec<Field>,
}
pub struct Field {
pub id: FieldId, // stable random hex; label is separate
pub label: String,
pub kind: FieldKind,
pub value: FieldValue,
pub hidden_by_default: bool,
}
pub enum FieldKind {
Text, Multiline, Password, Concealed, Url, Email, Phone,
Date, MonthYear, Totp, Reference,
}
pub enum FieldValue {
Text(String),
Multiline(String),
Password(Zeroizing<String>),
Concealed(Zeroizing<String>),
Url(Url),
Email(String),
Phone(String),
Date(NaiveDate),
MonthYear(MonthYear),
Totp(TotpConfig),
Reference(AttachmentId), // pointer into Item.attachments
}
```
`FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing to a single `enum FieldKindAndValue`) so the kind can be queried without inspecting the value. Validation invariant: `kind` and `value`'s discriminants must match, enforced at construction and during deserialization.
### Field history
```rust
pub struct FieldHistoryEntry {
pub value: Zeroizing<String>, // serialized form of the previous value
pub replaced_at: i64,
}
```
Triggered automatically in the Item setter for any field whose `kind ∈ {Password, Concealed, Totp}`. Vault settings drive retention (default forever; configurable to N most-recent or N days).
For `Totp`, the stored history value is the base32 secret string (not the parsed bytes), keeping history serializable across rotations of digits/algorithm/period.
### Attachments
```rust
pub struct AttachmentRef {
pub id: AttachmentId, // sha256 of plaintext, hex-truncated to 16
pub filename: String,
pub mime_type: String,
pub size: u64, // plaintext size in bytes
pub created: i64,
}
```
The `AttachmentRef` lives on the Item; the actual bytes live in `attachments/<item_id>/<aid>.enc`.
### Generators
```rust
pub enum GeneratorRequest {
Bip39 {
word_count: u32, // default 5
separator: String, // default " ", selectable: "-", "_", ".", ":", ""
capitalization: Capitalization,
},
Random {
length: u32, // 4..=128
classes: CharClasses, // {lower, upper, digits, symbols} bitmask
symbol_charset: SymbolCharset, // SafeOnly | Extended | Custom(String)
},
}
pub enum Capitalization { Lower, Upper, FirstOfEach, Title, Mixed }
pub struct CharClasses {
pub lower: bool, pub upper: bool, pub digits: bool, pub symbols: bool,
}
pub enum SymbolCharset {
SafeOnly, // !@#$%^&*-_=+
Extended, // SafeOnly + a few more, still excluding '"`,;:{}[]<>()|\\/?
Custom(String),
}
```
Single canonical generator implementation in `idfoto-core`, exposed via WASM and used by CLI directly. Both paths use `getrandom`-backed `OsRng` and `rand::distributions::Uniform` for unbiased sampling.
## Storage, Manifest & Sync
### Repo layout
```
.idfoto/
salt # 32-byte vault salt (KDF input)
params.json # Argon2id parameters, format version
devices.json # authorized device ed25519 pubkeys
items/<id>.enc # full Item, JSON-then-AEAD
attachments/<item_id>/<aid>.enc # binary blob, AEAD'd separately
manifest.enc # browse index
settings.enc # vault-level settings
```
Per-attachment encryption: each attachment has its own random 24-byte XChaCha20 nonce, encrypted with the same vault master key. Filename `<aid>` is content-addressed (`sha256(plaintext)`, hex-truncated to 16 chars). Same plaintext stored twice produces the same file, allowing git deduplication.
### Manifest schema
`manifest.enc` is fully decrypted on every unlock and drives the popup browse view:
```rust
pub struct Manifest {
pub schema_version: u32, // currently 2
pub items: HashMap<ItemId, ManifestEntry>,
}
pub struct ManifestEntry {
pub id: ItemId,
pub r#type: ItemType,
pub title: String,
pub tags: Vec<String>,
pub favorite: bool,
pub group: Option<String>,
pub icon_hint: Option<String>,
pub modified: i64,
pub trashed_at: Option<i64>,
pub attachment_summaries: Vec<AttachmentSummary>,
}
pub struct AttachmentSummary {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
pub size: u64,
}
```
The manifest carries enough to render the full browse list — icons, titles, tags, favorites, attachment indicators, last-modified — with **zero per-item decrypts**. Opening an item triggers exactly one `items/<id>.enc` decrypt. Editing a non-displayed field touches only the item file (no manifest churn). Editing a displayed field touches both.
### Vault settings
`settings.enc`:
```rust
pub struct VaultSettings {
pub trash_retention: TrashRetention,
pub field_history_retention: HistoryRetention,
pub generator_defaults: GeneratorRequest, // user's preferred generator config
pub attachment_caps: AttachmentCaps,
pub autofill_origin_acks: HashMap<String, i64>, // hostname → unix-seconds first-acked
}
pub enum TrashRetention { Days(u32), Forever } // default Days(30)
pub enum HistoryRetention { LastN(u32), Days(u32), Forever } // default Forever
pub struct AttachmentCaps {
pub per_attachment_max_bytes: u64, // default 10 * 1024 * 1024
pub per_item_max_count: u32, // default 20
pub per_vault_soft_cap_bytes: u64, // default 100 * 1024 * 1024
pub per_vault_hard_cap_bytes: u64, // default 500 * 1024 * 1024
}
```
### Sync semantics
| Operation | Files written | Commit shape |
|---|---|---|
| Item add | `items/<id>.enc`, `manifest.enc` | one commit |
| Item edit (non-displayed field) | `items/<id>.enc` | one commit |
| Item edit (displayed field) | `items/<id>.enc`, `manifest.enc` | one commit |
| Item soft-delete | `items/<id>.enc` (sets `trashed_at`), `manifest.enc` | one commit |
| Item purge (post-retention) | delete `items/<id>.enc` + `attachments/<id>/*`, `manifest.enc` | one commit |
| Attachment add | `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit |
| Attachment delete | delete `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit |
| Settings change | `settings.enc` | one commit |
Conflict handling: existing CLI flow (`git pull --rebase` before push) remains. Two devices editing the same item produce a merge conflict on `items/<id>.enc` (binary AEAD ciphertext, not auto-mergeable). MVP behavior: detect conflict, prompt user to choose a side. Field-level merge by decrypting both sides is post-MVP.
### Large-blob upload path (extension)
`extension/src/service-worker/git-host.ts` gains a `putBlob(payload)` that:
- Uses GitHub/Gitea Contents API for payloads ≤ ~900KB (single PUT with base64 body).
- Falls back to Git Data API for larger payloads (create blob → create tree → create commit → update ref — three round-trips).
All `attachments/*` writes go through this path. Item / manifest / settings files are always small enough for the Contents API.
## Cryptographic Envelope
### Key derivation (audit H1, H2, H3)
```rust
pub fn derive_master_key(
passphrase: &str,
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &Argon2Params,
) -> Result<Zeroizing<[u8; 32]>, IdfotoError> {
let passphrase_nfc = passphrase.nfc().collect::<String>(); // normalize once
let mut password = Zeroizing::new(
Vec::with_capacity(8 + passphrase_nfc.len() + 8 + 32)
);
password.extend_from_slice(&(passphrase_nfc.len() as u64).to_be_bytes());
password.extend_from_slice(passphrase_nfc.as_bytes());
password.extend_from_slice(&32u64.to_be_bytes());
password.extend_from_slice(image_secret);
let mut master_key = Zeroizing::new([0u8; 32]);
let argon2 = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(params.m, params.t, params.p, Some(32))?,
);
argon2.hash_password_into(&password, salt, master_key.as_mut())?;
Ok(master_key)
}
```
- **Length-prefixed inputs** eliminate the `("abc",[0x44,…]) ≡ ("abcD",[…])` ambiguity (audit H1).
- **`Zeroizing` everywhere** — `password` Vec, `master_key` array. Sensitive plaintext fields use the same wrapper at the struct level (audit H2).
- **UTF-8 NFC normalization** of passphrase before length-prefixing eliminates the macOS NFD edge case.
### Passphrase strength gate (audit H3)
`zxcvbn` enforced at vault creation:
```rust
pub fn validate_passphrase_strength(p: &str) -> Result<(), WeakPassphrase> {
let estimate = zxcvbn::zxcvbn(p, &[]);
if estimate.guesses_log10() < 13.5 { // ~2^45 guesses
return Err(WeakPassphrase {
score: estimate.score(),
feedback: estimate.feedback().cloned(),
});
}
Ok(())
}
```
Visual color-coded slider in the setup wizard (and in the future admin portal's "change passphrase" flow) renders `score` 0-4 with feedback text. Vault creation refuses to proceed below `score >= 3` (≈ 2^45 guesses) without an explicit "I understand the risk" confirmation.
### AEAD envelope
Per-encryption layout (item, manifest, settings, each attachment):
```
[VERSION_BYTE][24-byte nonce][AEAD ciphertext + 16-byte tag]
```
- `VERSION_BYTE = 0x02` (clean break — no v1 compat).
- XChaCha20-Poly1305 (already correct, audit confirmed-safe #1).
- Fresh `OsRng`-derived nonce per encryption.
- Decrypt failure returns opaque `IdfotoError::Decrypt` regardless of which validation tripped (audit M4).
### RNG (audit H5, H6)
- `idfoto-wasm` uses `getrandom` (with `js` feature) for password generation, item IDs, attachment IDs. **No `Math.random()` anywhere.**
- Modulo-bias eliminated via `rand::distributions::Uniform` for charset sampling — both CLI and WASM paths.
- Single canonical `generate_password` and `generate_bip39` in `idfoto-core`, exposed to WASM and called directly by CLI.
### ID format (audit M8)
- `ItemId`, `FieldId`, `AttachmentId`: 16 hex chars (64 bits) generated via `OsRng.fill_bytes(&mut [u8; 8])` → hex.
- `AttachmentId` deviates: it's `sha256(plaintext).hex()[..16]` for content-addressing.
### Per-vault crypto metadata
`.idfoto/params.json`:
```json
{
"format_version": 2,
"kdf": { "algorithm": "argon2id-v0x13", "m": 65536, "t": 3, "p": 4 },
"aead": "xchacha20poly1305",
"salt_path": ".idfoto/salt"
}
```
Format version present from day one so future migrations have a hook.
Three version fields exist intentionally and evolve independently:
| Field | Where | Bumps when |
|---|---|---|
| `format_version` | `.idfoto/params.json` | Overall vault layout changes (file structure, KDF construction, anything cross-cutting) |
| `schema_version` | inside `manifest.enc` | Manifest entry shape changes only (e.g., adding a new field to `ManifestEntry`) |
| `VERSION_BYTE` | first byte of every AEAD blob | AEAD construction itself changes (cipher, nonce size, tag layout) |
All three set to `2` for the initial typed-item release. Future bumps are independent: e.g., adding a manifest field is `schema_version` only; switching to a new AEAD is `VERSION_BYTE` only; changing the on-disk file structure is `format_version` only.
## Security Architecture
### Manifest changes (audit C1)
- `setup.html` and `setup.js` **removed from `web_accessible_resources`** in both `extension/manifest.json` and `extension/manifest.firefox.json`.
- The popup opens setup via `chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })` — own-origin extension tabs work without WAR.
- WASM artifacts (`idfoto_wasm.js`, `idfoto_wasm_bg.wasm`) removed from WAR — service worker loads them via `import` from extension origin.
### Split message router (audit C1, C2, C4)
Directory layout:
```
extension/src/service-worker/
router/
popup-only.ts // unlock, lock, list_items, get_item, add/update/delete,
// save_setup, generate_password, vault settings, ...
content-callable.ts // get_autofill_candidates, get_credentials,
// check_credential, fill_credentials, blacklist_site
index.ts // single onMessage entry, dispatches by sender check
session.ts // opaque session-handle table mapping handle → master_key
vault.ts // typed-item operations (was vault.ts; rewritten)
git-host.ts // gains putBlob with Contents/Git Data fallback
```
Dispatch logic (single `chrome.runtime.onMessage` entry point):
```ts
const POPUP_ONLY: ReadonlySet<MessageType> = new Set([
'unlock', 'lock', 'list_items', 'get_item', 'add_item', 'update_item',
'delete_item', 'purge_item', 'restore_item', 'get_totp', 'save_setup',
'get_setup_state', 'update_settings', 'get_settings', 'add_attachment',
'get_attachment', 'delete_attachment', 'generate_password',
'generate_passphrase', 'rate_passphrase', 'change_passphrase',
'list_devices', 'add_device', 'revoke_device',
]);
const CONTENT_CALLABLE: ReadonlySet<MessageType> = new Set([
'get_autofill_candidates', 'get_credentials', 'check_credential',
'fill_credentials', 'blacklist_site',
]);
chrome.runtime.onMessage.addListener((msg, sender, reply) => {
const senderUrl = sender.url ?? '';
const isPopup = senderUrl === chrome.runtime.getURL('popup.html');
const isSetup = senderUrl.startsWith(chrome.runtime.getURL('setup.html'));
const isContent =
sender.tab !== undefined &&
sender.frameId === 0 &&
sender.id === chrome.runtime.id;
if (POPUP_ONLY.has(msg.type)) {
if (!isPopup && !(msg.type === 'save_setup' && isSetup)) {
reply({ ok: false, error: 'unauthorized_sender' });
return false;
}
return popupOnly.handle(msg, sender, reply);
}
if (CONTENT_CALLABLE.has(msg.type)) {
if (!isContent) {
reply({ ok: false, error: 'unauthorized_sender' });
return false;
}
return contentCallable.handle(msg, sender, reply);
}
reply({ ok: false, error: 'unknown_message_type' });
return false;
});
```
### Origin-bound autofill (audit C4)
- `get_autofill_candidates` ignores any `url` in the message body. Uses `sender.tab.url` only.
- `get_credentials(id)` looks up the item, derives `entry.url`'s hostname, compares to `sender.tab.url`'s hostname. Mismatch returns `{ ok: false, error: 'origin_mismatch' }` — no leak.
- Top-frame only: `sender.frameId === 0` required (no autofill into iframes).
- TOFU origin acknowledgement: first autofill on any new hostname requires the user to confirm in the popup. Acknowledged hostnames stored in `VaultSettings.autofill_origin_acks`.
- Pre-popup-fill (`fill_credentials` from popup): when popup opens, capture `(tab.id, tab.url)`. Send `fill_credentials` with that captured tab id, and verify on receipt that the entry's stored URL matches the captured tab's hostname. If user switches tabs mid-flow, the fill is rejected (audit M5).
### Capture prompt rendering (audit C3)
All page-injected UI (`content/capture.ts`, `content/icon.ts`) lives inside a **closed Shadow DOM**:
```ts
const host = document.createElement('div');
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'closed' });
const promptDom = buildPromptDom(values); // textContent only, no innerHTML
root.appendChild(promptDom);
```
Strict rules for content-script DOM construction:
1. **No `innerHTML` anywhere in content scripts.** All construction via `document.createElement` + `.textContent =`.
2. **Element IDs randomized per-prompt** (no stable `idfoto-save-btn` for page collisions). Use a per-prompt `Map<string, HTMLElement>` to wire up handlers.
3. **Page-derived values bounded** — username field from `findUsernameValue` capped at 256 chars, control characters stripped, then assigned only via `.textContent`.
4. **CSS scoped via Shadow DOM** — no leak to/from page CSS.
The popup UI (which lives in the extension origin, not page DOM) continues to use the existing `escapeHtml` textContent pattern in `popup.ts:16-20`. Audit L11 (single-quote attribute escaping) is mitigated by mandating double-quote attributes via lint rule.
### Memory hygiene (audit H2)
- **Rust core**: `Zeroizing<>` wrappers as defined in the data model section.
- **WASM bridge**: master_key NEVER returned to JS as `Uint8Array`. Instead:
```rust
#[wasm_bindgen]
pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8])
-> Result<SessionHandle, JsError> { ... }
#[wasm_bindgen]
pub fn list_items(handle: SessionHandle) -> Result<JsValue, JsError> { ... }
```
`SessionHandle` is an opaque `u32` index into a Rust-side `HashMap<u32, Zeroizing<[u8; 32]>>` (the `session.rs` module). Keys live entirely in WASM linear memory inside `Zeroizing<>` structures. `lock(handle)` clears the entry. SW idle-suspend drops all sessions automatically.
- **JS side**: passphrase string cleared from local variables ASAP after passing to WASM. Best-effort only (JS strings are immutable) — primary defense is keeping passphrase in scope for as little time as possible.
### Hardened CLI git shell-out (audit H4)
```rust
fn git_command(args: &[&str]) -> Command {
let mut cmd = Command::new("git");
cmd.args([
"-c", "core.hooksPath=/dev/null",
"-c", "commit.gpgsign=false",
"-c", "core.editor=true", // skip editor on rebase conflict markers
]);
cmd.args(args);
cmd
}
```
Stage specific paths instead of `git add -A`:
```rust
git_command(&[
"add",
&format!("items/{}.enc", id),
"manifest.enc",
])
```
### chrome.storage.local hardening (audit H8)
- `apiToken` and `imageBase64` documented as profile-disk-readable in README + setup wizard final screen ("anyone with filesystem access to your browser profile owns factor 2 + the git push token; your remaining defense is the passphrase").
- Setup wizard PAT instructions emphasize fine-grained PATs (Contents-only, single repo) for both GitHub and Gitea.
### Deferred to Phase 0 implementation
These audit items are bundled into the Phase 0 remediation plan (not this Phase 1 design):
- M3: imgsecret `MAX_DIMENSION` cap (10000px) and dimension peek before decode.
- M5: popup→fill captured-tab verification (covered by autofill section above; implementation in Phase 0).
- M6: CLI clipboard always-clear + `Zeroizing<String>` wrap.
- M7: CLI stdout `Password: ********` by default + `--show` flag.
- M11: CLI ISO-8601 timestamp formatting.
- L7: `cargo audit` / `cargo deny` CI configuration.
- L8: CLI vault-dir detection (refuse to operate outside an `.idfoto/`-marked directory).
## WASM API Surface
The opaque session-handle pattern shapes the WASM API. All operations after `unlock` take a `SessionHandle`.
```rust
#[wasm_bindgen]
pub struct SessionHandle(u32);
#[wasm_bindgen]
pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8],
params_json: &str) -> Result<SessionHandle, JsError>;
#[wasm_bindgen]
pub fn lock(handle: SessionHandle);
// Manifest + items
#[wasm_bindgen] pub fn manifest_load(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
#[wasm_bindgen] pub fn manifest_serialize(handle: SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen] pub fn item_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
#[wasm_bindgen] pub fn item_encrypt(handle: SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen] pub fn settings_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
#[wasm_bindgen] pub fn settings_encrypt(handle: SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError>;
// Attachments
#[wasm_bindgen] pub fn attachment_encrypt(handle: SessionHandle, plaintext: &[u8]) -> Result<EncryptedAttachment, JsError>;
#[wasm_bindgen] pub fn attachment_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen]
pub struct EncryptedAttachment {
pub aid: String, // sha256 of plaintext, 16-char hex
pub bytes: Vec<u8>,
}
// Generators
#[wasm_bindgen] pub fn generate_password(request_json: &str) -> Result<String, JsError>;
#[wasm_bindgen] pub fn generate_passphrase(request_json: &str) -> Result<String, JsError>;
#[wasm_bindgen] pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError>;
// TOTP
#[wasm_bindgen] pub fn totp_compute(handle: SessionHandle, item_id: &str, field_id: &str, now_unix: u64) -> Result<TotpCode, JsError>;
#[wasm_bindgen] pub struct TotpCode { pub code: String, pub expires_at: u64 }
// Image-secret extraction (called during unlock; signature unchanged from today)
#[wasm_bindgen] pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError>;
#[wasm_bindgen] pub fn embed_image_secret(image_bytes: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError>;
// Item ID generation
#[wasm_bindgen] pub fn new_item_id() -> String; // 16-char hex
#[wasm_bindgen] pub fn new_field_id() -> String;
```
`JsValue` returns are `serde_wasm_bindgen`-serialized typed structs. The TS extension consumes them via generated declarations in `extension/src/wasm.d.ts`.
## CLI Surface
New commands and renamed semantics:
```bash
# Existing (semantics carry forward, terminology updated to "item")
idfoto init
idfoto unlock # unlocks for next command
idfoto lock
idfoto sync # git pull --rebase + push, hardened
idfoto generate [--length N] [--bip39 [--words N]] [--symbols safe|extended]
idfoto device <add|list|revoke>
# Updated for typed items
idfoto add <type> [--title T] [--group G] [--tags t1,t2] [--favorite]
[...type-specific fields, e.g., --username, --url, --password-prompt]
idfoto get <id-or-title> # always concealed by default; --show to reveal
idfoto list [--type T] [--group G] [--tag T] [--trashed]
idfoto edit <id-or-title> # interactive prompts for fields to update
# (no $EDITOR with plaintext — temp-file leak risk)
idfoto rm <id-or-title> # soft-delete (trash)
idfoto restore <id-or-title> # restore from trash
idfoto purge <id-or-title> # hard-delete (also purges attachments)
idfoto trash empty # hard-delete all past retention
# New for attachments
idfoto attach <id-or-title> <file> # adds file as attachment
idfoto attachments <id-or-title> # list attachments on item
idfoto extract <id-or-title> <aid> [--out path] # decrypt + save to disk
# Settings
idfoto settings get [<key>]
idfoto settings set <key> <value> # e.g., trash_retention=days:60
```
`idfoto get` shows password as `********` by default. `--show` is required to print plaintext. Clipboard auto-clear unconditional after 30s with `Zeroizing<String>` wrap (audit M6, M7).
`vault_dir()` detection: traverses up from CWD looking for `.idfoto/`. Refuses to operate without one (audit L8).
## Browser Extension UI Implications
This spec doesn't enumerate every UI screen — that's Phase 5 territory — but the data model imposes shape on the popup:
- **List view**: rendered from manifest only. Per-item: type-icon, title, group/tags, favorite indicator, attachment-count badge.
- **Detail view**: per-type form. Each type's form lives in `extension/src/popup/components/items/<type>.ts` (mirrors the per-type module on the Rust side). Adding a new type later means adding the Rust core file + the TypeScript form file + entries in two enum-like dispatchers.
- **Field rendering**: each `FieldKind` has a known renderer (`Password` → reveal-toggle + copy + generate; `Totp` → rotating display + countdown bar; `Url` → click-to-open; etc.).
- **Custom fields**: rendered in their `Section`, with the user able to add/remove/rename sections and fields inline.
- **History view**: per-item button shows `field_history` table (timestamps + reveal-toggle for old values).
- **Trash view**: filtered list of items where `trashed_at != null`, with restore + purge actions.
- **Settings view**: vault-level retention/generators/caps. Existing capture/blacklist settings move into this consolidated view.
Setup wizard, capture flow, autofill icon, and unlock screen all continue to exist in their current locations but updated for the security architecture (closed Shadow DOM for capture; popup-only sender check for setup).
## Testing Strategy
### Unit tests (Rust)
- **`item_types/`**: per-type round-trip tests (construct → serialize → deserialize → equal), boundary cases (empty optional fields, max-length strings).
- **`field_history`**: setter triggers history on Password/Concealed/Totp; setter ignores history on Text/Url/etc.; retention pruning honors `LastN`/`Days`/`Forever` modes.
- **`crypto`**: length-prefix construction round-trip; verify two distinct `(passphrase, image_secret)` pairs produce distinct master keys (extends existing two-factor independence test); Zeroize-drop test using `tracking_allocator` to verify wipe.
- **`generators`**: bip39 produces requested word count; random-charset honors class toggles; Uniform sampling produces no measurable bias over 100k samples per charset.
- **`session`**: handle table inserts/removes; `lock()` clears the underlying buffer.
### Integration tests (Rust)
- **`tests/typed_items.rs`**: full workflow — init vault, add Login + Card + Document + TOTP, list, edit (verify history captured), soft-delete, restore, purge.
- **`tests/migration.rs`**: explicit "v1 vault is rejected" test (no compat shim — confirm the new client refuses old format with a clear error).
- **Existing `tests/integration.rs`**: keep the two-factor independence + full-workflow tests, port to the new Item type.
### WASM tests
- **`wasm-bindgen-test`** for: session handle lifecycle (`unlock` → `list_items` → `lock`), generator output sanity, RFC 6238 TOTP test vectors, attachment round-trip, manifest round-trip.
- **Browser-flavored test**: load WASM in a headless Chrome via `wasm-pack test --chrome --headless`.
### Extension tests
- **Service worker router**: mock `chrome.runtime.onMessage` and verify each message type is rejected when sent from the wrong sender (popup-only from content, content-callable from popup, anything from external).
- **Origin-bound autofill**: mock `sender.tab.url` and verify cross-origin requests are rejected even when the content script asks nicely.
- **Closed Shadow DOM**: render the capture prompt, verify the page-side `document.querySelector('#idfoto-save-btn')` returns null.
- **Generator**: verify no `Math.random()` reachable from any extension entry point (lint rule + runtime probe).
### Manual / observational
- **Heap snapshot of SW after unlock**: inspect WASM linear memory in DevTools and verify master_key bytes are not visible from JS.
- **GitHub + Gitea + self-hosted Gitea**: full add → attach 5MB doc → sync round-trip on each.
- **Conflict reproduction**: two devices edit the same item, verify merge-conflict prompt fires.
## Open Questions / Deferred to Plan
- Exact UI shape for the per-type forms (Phase 5 concern — but Phase 1 implementation will land minimal viable forms for each of the 7 types so the data model is exercisable).
- Field-level merge for conflicting item edits (post-MVP).
- Item-to-item references (e.g., a Login that points at a Key for SSH) — `FieldKind::Reference` currently only points at attachments; expand to ItemReference in a later phase if useful.
- Per-attachment encryption key derivation — currently using the master key directly with a fresh nonce per file. Consider a per-attachment subkey via HKDF for additional defense-in-depth (post-MVP).
- Steam Guard TOTP encoding details.
- HOTP counter conflict resolution. Counter lives in `TotpConfig.kind = Hotp(counter)`, persisted in the item file; each code generation rewrites the item with `counter + 1`. Sync conflicts on a HOTP counter resolve to `max(local, remote)` (advancing past either side's last-used code is correct; falling back is not). To be enforced in the conflict-merge code path.
## Appendix A — Audit Findings Addressed by This Design
| Audit ID | Severity | How this spec addresses it |
|---|---|---|
| C1 | Critical | Setup wizard removed from WAR; sender check on `save_setup` |
| C2 | Critical | Split message router with sender-based dispatch |
| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI |
| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match required) |
| H1 | High | Length-prefixed `passphrase \|\| image_secret`; NFC normalization |
| H2 | High | `Zeroizing<>` everywhere; opaque session handles (master_key never crosses WASM boundary) |
| H3 | High | `zxcvbn` strength gate at vault creation |
| H4 | High | Hardened git shell-out (no hooks, no GPG sign, no editor; specific paths) |
| H5 | High | `getrandom` for all randomness in WASM; no `Math.random()` |
| H6 | High | `rand::distributions::Uniform` in CLI generator |
| H7 | High | Bump `rpassword` to 7.x (Phase 0) |
| H8 | High | Documented in setup wizard + README; fine-grained PAT guidance |
| M4 | Medium | Opaque `IdfotoError::Decrypt` for all decrypt failures |
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
| M8 | Medium | 16-char hex IDs |
| M9 | Medium | Item type discriminant validation in deserializer |
| L11 | Low | Lint rule mandates double-quote attributes in templates |
Phase 0 implementation handles the remaining items (M3, M6, M7, M11, L7, L8) outside this spec.
## Appendix B — Files Touched
New files (Rust):
```
crates/idfoto-core/src/item.rs # Item, Section, Field, FieldKind, FieldValue
crates/idfoto-core/src/item_types/mod.rs
crates/idfoto-core/src/item_types/login.rs
crates/idfoto-core/src/item_types/secure_note.rs
crates/idfoto-core/src/item_types/identity.rs
crates/idfoto-core/src/item_types/card.rs
crates/idfoto-core/src/item_types/key.rs
crates/idfoto-core/src/item_types/document.rs
crates/idfoto-core/src/item_types/totp.rs
crates/idfoto-core/src/manifest.rs # rewritten
crates/idfoto-core/src/settings.rs
crates/idfoto-core/src/generators.rs
crates/idfoto-core/src/attachment.rs
crates/idfoto-wasm/src/session.rs
```
New files (extension):
```
extension/src/service-worker/router/index.ts
extension/src/service-worker/router/popup-only.ts
extension/src/service-worker/router/content-callable.ts
extension/src/popup/components/items/login.ts
extension/src/popup/components/items/secure-note.ts
extension/src/popup/components/items/identity.ts
extension/src/popup/components/items/card.ts
extension/src/popup/components/items/key.ts
extension/src/popup/components/items/document.ts
extension/src/popup/components/items/totp.ts
extension/src/popup/components/trash.ts
extension/src/popup/components/history.ts
```
Heavily modified (Rust):
```
crates/idfoto-core/src/lib.rs # re-exports + module declarations
crates/idfoto-core/src/crypto.rs # length-prefix KDF, Zeroize, NFC
crates/idfoto-core/src/entry.rs # DELETED — replaced by item.rs
crates/idfoto-core/src/error.rs # opaque Decrypt variant only
crates/idfoto-core/Cargo.toml # add zeroize, zxcvbn, bip39, unicode-normalization
crates/idfoto-wasm/src/lib.rs # session-handle API, getrandom
crates/idfoto-wasm/Cargo.toml # update deps
crates/idfoto-cli/src/main.rs # rewritten command handlers
crates/idfoto-cli/Cargo.toml # rpassword = "7", clipboard hardening
```
Heavily modified (extension):
```
extension/manifest.json # WAR cleanup
extension/manifest.firefox.json # WAR cleanup
extension/src/service-worker/index.ts # → router/index.ts
extension/src/service-worker/vault.ts # typed-item operations
extension/src/service-worker/git-host.ts # putBlob with Git Data API fallback
extension/src/service-worker/gitea.ts # putBlob impl
extension/src/service-worker/github.ts # putBlob impl
extension/src/content/capture.ts # closed Shadow DOM
extension/src/content/icon.ts # closed Shadow DOM
extension/src/content/detector.ts # bound page-derived strings
extension/src/popup/popup.ts # typed-item dispatch
extension/src/popup/components/entry-list.ts # → item-list.ts
extension/src/popup/components/entry-detail.ts # → dispatcher to per-type detail
extension/src/popup/components/entry-form.ts # → dispatcher to per-type form
extension/src/popup/components/settings.ts # vault settings (retention, generators, caps)
extension/src/popup/components/setup-wizard.ts # zxcvbn integration
extension/src/setup/setup.ts # zxcvbn integration
extension/src/shared/types.ts # Item, ItemType, FieldKind, etc.
extension/src/shared/messages.ts # split per router surface
extension/src/wasm.d.ts # session-handle types
```
Documentation:
```
README.md # update for typed items, security warnings
CLAUDE.md # reflect new module structure
docs/superpowers/specs/2026-04-11-idfoto-design.md # amend KDF section per H1; note format v2
```