refactor(core): delete entry.rs; finalize typed-item lib.rs re-exports
The old Entry/ManifestEntry/Manifest types are gone. CLI/extension references break and will be fixed by Plans 1B and 1C respectively. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<id>.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/<id>.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<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub password: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub totp_secret: Option<String>,
|
|
||||||
/// Optional group for organizing entries (e.g. "work", "personal").
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub group: Option<String>,
|
|
||||||
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<String>,
|
|
||||||
/// Account username for display in entry listings.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub username: Option<String>,
|
|
||||||
/// Optional group for organizing entries (e.g. "work", "personal").
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub group: Option<String>,
|
|
||||||
/// 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<String, ManifestEntry>,
|
|
||||||
/// 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<ManifestEntry> {
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,17 +10,22 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Modules
|
//! ## Modules
|
||||||
//!
|
//!
|
||||||
//! - [`error`] -- The unified error type ([`IdfotoError`]) used across the crate.
|
//! - [`error`] — The unified error type ([`IdfotoError`]).
|
||||||
//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated
|
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
||||||
//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer.
|
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
||||||
//! - [`entry`] -- The vault data model: [`Entry`] (full credential),
|
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
||||||
//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry
|
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
||||||
//! index that lets you list/search without decrypting every entry).
|
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
||||||
//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON
|
//! `ItemCore`/`ItemType` enums.
|
||||||
//! before encrypting, and deserialize after decrypting.
|
//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`,
|
||||||
//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a
|
//! `FieldHistoryEntry`.
|
||||||
//! 256-bit secret in a JPEG image. This is the novel component that provides the
|
//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers.
|
||||||
//! second authentication factor.
|
//! - [`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
|
//! ## Crypto pipeline
|
||||||
//!
|
//!
|
||||||
@@ -35,6 +40,9 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub use error::{IdfotoError, Result};
|
pub use error::{IdfotoError, Result};
|
||||||
|
|
||||||
|
pub mod crypto;
|
||||||
|
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
||||||
|
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||||
|
|
||||||
@@ -62,12 +70,6 @@ pub use settings::{
|
|||||||
pub mod generators;
|
pub mod generators;
|
||||||
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
|
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 mod vault;
|
||||||
pub use vault::{
|
pub use vault::{
|
||||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||||
|
|||||||
Reference in New Issue
Block a user