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:
920
docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md
Normal file
920
docs/superpowers/specs/2026-04-18-idfoto-typed-items-design.md
Normal 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` (C1–C4, H1–H8) |
|
||||
| **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 C1–C4, H1–H8 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
|
||||
```
|
||||
Reference in New Issue
Block a user