diff --git a/crates/idfoto-core/src/entry.rs b/crates/idfoto-core/src/entry.rs deleted file mode 100644 index 228b0b6..0000000 --- a/crates/idfoto-core/src/entry.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! Vault data model: entries, manifest entries, and the manifest index. -//! -//! The vault stores credentials in two tiers: -//! -//! 1. **Individual entries** (`entries/.enc`): each file contains a single -//! [`Entry`] encrypted with the master key. Only decrypted when the user -//! needs to read or edit a specific credential. -//! -//! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a -//! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries. -//! This lets the CLI list and search entries by decrypting only one file, -//! rather than decrypting every entry in the vault. -//! -//! ## Entry IDs -//! -//! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy, -//! ~4 billion possible values). This is sufficient for family-scale vaults while -//! keeping filenames short and filesystem-friendly. -//! -//! ## Serialization strategy -//! -//! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields -//! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact -//! -- omitting null fields reduces ciphertext size and avoids leaking structural -//! information about which optional fields a credential uses. - -use rand::Rng; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// A full credential entry stored encrypted in `entries/.enc`. -/// -/// Contains all sensitive data for a single credential. Each entry is encrypted -/// independently, so accessing one entry does not require decrypting others. -/// -/// ## Fields -/// -/// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required. -/// - `url`: the login URL. Optional; used for autofill matching in the browser extension. -/// - `username`: the account username or email. Optional. -/// - `password`: the credential password. Required (this is the core secret). -/// - `notes`: free-form text (e.g., security questions, recovery codes). Optional. -/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional. -/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created. -/// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification. -/// - `group`: optional group label for organizing entries (e.g. "work", "personal"). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Entry { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub username: Option, - pub password: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub totp_secret: Option, - /// Optional group for organizing entries (e.g. "work", "personal"). - #[serde(skip_serializing_if = "Option::is_none")] - pub group: Option, - pub created_at: String, - pub updated_at: String, -} - -/// Summary metadata for a single entry, stored in the manifest. -/// -/// This is a lightweight projection of [`Entry`] that contains only the -/// non-sensitive fields needed for listing and searching. The password, -/// notes, and TOTP secret are intentionally excluded so that listing -/// entries requires decrypting only the manifest, not every individual entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManifestEntry { - /// Human-readable label for display and search matching. - pub name: String, - /// Login URL for search matching and browser extension autofill. - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, - /// Account username for display in entry listings. - #[serde(skip_serializing_if = "Option::is_none")] - pub username: Option, - /// Optional group for organizing entries (e.g. "work", "personal"). - #[serde(skip_serializing_if = "Option::is_none")] - pub group: Option, - /// Timestamp of last modification, used for sorting and display. - pub updated_at: String, -} - -/// The vault manifest -- an encrypted index mapping entry IDs to their metadata. -/// -/// The manifest serves two purposes: -/// -/// 1. **Efficient listing**: decrypting the single manifest file is enough to show -/// all entry names, URLs, and usernames without touching individual entry files. -/// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive -/// substring matching against entry names and URLs. -/// -/// The `version` field allows future schema migrations if the manifest format evolves. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Manifest { - /// Map from entry ID (8-char hex string) to entry metadata. - pub entries: HashMap, - /// Schema version. Currently always `1`. - pub version: u32, -} - -impl Manifest { - /// Create a new empty manifest with version 1. - pub fn new() -> Self { - Manifest { - entries: HashMap::new(), - version: 1, - } - } - - /// Insert or update an entry in the manifest. - /// - /// If an entry with the same ID already exists, it is overwritten. - /// This is used both for `add` (new entry) and `edit` (update existing). - pub fn add_entry(&mut self, id: String, entry: ManifestEntry) { - self.entries.insert(id, entry); - } - - /// Remove an entry from the manifest by ID, returning its metadata if it existed. - pub fn remove_entry(&mut self, id: &str) -> Option { - self.entries.remove(id) - } - - /// Search entries by case-insensitive substring match against name and URL. - /// - /// Returns a vector of `(id, entry)` pairs for all matching entries. An entry - /// matches if the query appears in its name or URL (case-insensitive). - pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> { - let q = query.to_lowercase(); - self.entries - .iter() - .filter(|(_, e)| { - e.name.to_lowercase().contains(&q) - || e.url - .as_deref() - .map(|u| u.to_lowercase().contains(&q)) - .unwrap_or(false) - }) - .collect() - } -} - -impl Default for Manifest { - fn default() -> Self { - Self::new() - } -} - -/// Generate a random 8-character hex string to use as an entry ID. -/// -/// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`. -/// This gives ~4 billion possible values, which is more than sufficient for -/// a family-scale vault (typically < 1000 entries). -pub fn generate_entry_id() -> String { - let mut rng = rand::thread_rng(); - let bytes: [u8; 4] = rng.gen(); - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn entry_serialization_round_trip() { - let entry = Entry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - password: "s3cr3t".to_string(), - notes: None, - totp_secret: None, - group: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&entry).unwrap(); - let decoded: Entry = serde_json::from_str(&json).unwrap(); - - assert_eq!(decoded.name, entry.name); - assert_eq!(decoded.url, entry.url); - assert_eq!(decoded.username, entry.username); - assert_eq!(decoded.password, entry.password); - assert_eq!(decoded.notes, entry.notes); - } - - #[test] - fn manifest_add_and_lookup() { - let mut manifest = Manifest::new(); - let me = ManifestEntry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - manifest.add_entry("abc12345".to_string(), me); - - assert!(manifest.entries.contains_key("abc12345")); - assert_eq!(manifest.entries["abc12345"].name, "GitHub"); - - let removed = manifest.remove_entry("abc12345"); - assert!(removed.is_some()); - assert!(!manifest.entries.contains_key("abc12345")); - } - - #[test] - fn manifest_serialization_round_trip() { - let mut manifest = Manifest::new(); - manifest.add_entry( - "deadbeef".to_string(), - ManifestEntry { - name: "Gmail".to_string(), - url: Some("https://mail.google.com".to_string()), - username: Some("user@gmail.com".to_string()), - group: None, - updated_at: "2024-06-01T00:00:00Z".to_string(), - }, - ); - - let json = serde_json::to_string(&manifest).unwrap(); - let decoded: Manifest = serde_json::from_str(&json).unwrap(); - - assert_eq!(decoded.version, 1); - assert!(decoded.entries.contains_key("deadbeef")); - assert_eq!(decoded.entries["deadbeef"].name, "Gmail"); - } - - #[test] - fn generate_entry_id_is_8_hex_chars() { - let id = generate_entry_id(); - assert_eq!(id.len(), 8); - assert!(id.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn manifest_search_case_insensitive() { - let mut manifest = Manifest::new(); - manifest.add_entry( - "id001".to_string(), - ManifestEntry { - name: "GitHub Account".to_string(), - url: Some("https://github.com".to_string()), - username: None, - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - ); - manifest.add_entry( - "id002".to_string(), - ManifestEntry { - name: "Work Email".to_string(), - url: Some("https://mail.example.com".to_string()), - username: None, - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - ); - - // partial name match, case-insensitive - let results = manifest.search("github"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].1.name, "GitHub Account"); - - // partial URL match - let results = manifest.search("mail.example"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].1.name, "Work Email"); - - // no match - let results = manifest.search("nonexistent"); - assert_eq!(results.len(), 0); - } - - #[test] - fn entry_deserializes_without_group_field() { - // JSON from an older vault that has no "group" key — must deserialize with group: None - let json = r#"{ - "name": "OldEntry", - "url": "https://example.com", - "username": "bob", - "password": "hunter2", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" - }"#; - let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field"); - assert_eq!(entry.name, "OldEntry"); - assert_eq!(entry.group, None); - } - - #[test] - fn manifest_entry_deserializes_without_group_field() { - // JSON from an older manifest that has no "group" key — must deserialize with group: None - let json = r#"{ - "name": "OldEntry", - "url": "https://example.com", - "username": "bob", - "updated_at": "2024-01-01T00:00:00Z" - }"#; - let me: ManifestEntry = serde_json::from_str(json) - .expect("should deserialize ManifestEntry without group field"); - assert_eq!(me.name, "OldEntry"); - assert_eq!(me.group, None); - } - - #[test] - fn entry_with_group_round_trips() { - let entry = Entry { - name: "Work Laptop".to_string(), - url: None, - username: Some("alice@corp.example".to_string()), - password: "p@ssw0rd".to_string(), - notes: None, - totp_secret: None, - group: Some("work".to_string()), - created_at: "2024-03-15T00:00:00Z".to_string(), - updated_at: "2024-03-15T00:00:00Z".to_string(), - }; - - let json = serde_json::to_string(&entry).unwrap(); - // The group field should be present in the JSON output - assert!(json.contains("\"group\""), "serialized JSON should contain group field"); - assert!(json.contains("\"work\""), "serialized JSON should contain group value"); - - let decoded: Entry = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.name, "Work Laptop"); - assert_eq!(decoded.group, Some("work".to_string())); - } -} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index d524ac7..f65b670 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -10,17 +10,22 @@ //! //! ## Modules //! -//! - [`error`] -- The unified error type ([`IdfotoError`]) used across the crate. -//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated -//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer. -//! - [`entry`] -- The vault data model: [`Entry`] (full credential), -//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry -//! index that lets you list/search without decrypting every entry). -//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON -//! before encrypting, and deserialize after decrypting. -//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a -//! 256-bit secret in a JPEG image. This is the novel component that provides the -//! second authentication factor. +//! - [`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. //! //! ## Crypto pipeline //! @@ -35,6 +40,9 @@ 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}; @@ -62,12 +70,6 @@ pub use settings::{ pub mod generators; pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate}; -pub mod crypto; -pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; - -pub mod entry; -pub use entry::{generate_entry_id, Entry}; - pub mod vault; pub use vault::{ decrypt_item, decrypt_manifest, decrypt_settings,