chore: reconcile Plan 1A branch with idfoto→relicario rename

Renames crate directories and sweeps identifiers so Plan 1B can reference
the post-rename names throughout.

- git mv crates/idfoto-{core,cli,wasm} → crates/relicario-{core,cli,wasm}
- sed sweep: idfoto_core/idfoto-core/IdfotoError/IDFOTO_IMAGE/.idfoto/ etc.
- All 128 relicario-core tests pass post-sweep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 20:33:04 -04:00
parent 49b78203f8
commit 9c49e5e148
32 changed files with 172 additions and 172 deletions

View File

@@ -0,0 +1,27 @@
[package]
name = "relicario-core"
version = "0.1.0"
edition = "2021"
description = "Core library for idfoto password manager"
[dependencies]
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
rand = "0.8"
sha2 = "0.10"
ed25519-dalek = { version = "2", features = ["rand_core"] }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
# Typed-item additions
zeroize = { version = "1", features = ["zeroize_derive", "serde"] }
zxcvbn = { version = "3", default-features = false }
bip39 = { version = "2", default-features = false, features = ["std"] }
unicode-normalization = "0.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] }
hex = "0.4"
url = { version = "2", features = ["serde"] }
getrandom = "0.2"

View File

@@ -0,0 +1,166 @@
//! Attachment refs (carried on Item) and summaries (carried in Manifest).
//!
//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added
//! later in Task 22 once the crypto module is settled.
use serde::{Deserialize, Serialize};
use crate::ids::AttachmentId;
/// Reference to an attachment, carried on the Item record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentRef {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
/// Plaintext size in bytes.
pub size: u64,
/// Unix-seconds when this attachment was added.
pub created: i64,
}
/// Compact summary of an attachment, carried in the Manifest so the popup
/// can show attachment indicators without decrypting the item file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentSummary {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
pub size: u64,
}
impl From<&AttachmentRef> for AttachmentSummary {
fn from(r: &AttachmentRef) -> Self {
Self {
id: r.id.clone(),
filename: r.filename.clone(),
mime_type: r.mime_type.clone(),
size: r.size,
}
}
}
use zeroize::Zeroizing;
use crate::crypto::{decrypt, encrypt};
use crate::error::{RelicarioError, Result};
/// Encrypted attachment with the AID derived from plaintext content.
#[derive(Debug)]
pub struct EncryptedAttachment {
pub id: AttachmentId,
pub bytes: Vec<u8>,
}
/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`.
///
/// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`,
/// before any crypto work is done.
///
/// ## Call-site adaptation
///
/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via
/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`).
pub fn encrypt_attachment(
plaintext: &[u8],
master_key: &Zeroizing<[u8; 32]>,
max_bytes: u64,
) -> Result<EncryptedAttachment> {
if plaintext.len() as u64 > max_bytes {
return Err(RelicarioError::AttachmentTooLarge {
size: plaintext.len() as u64,
max: max_bytes,
});
}
let id = AttachmentId::from_plaintext(plaintext);
let bytes = encrypt(master_key, plaintext)?;
Ok(EncryptedAttachment { id, bytes })
}
/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext
/// wrapped in [`Zeroizing`] so it is wiped on drop.
///
/// ## Call-site adaptation
///
/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`.
pub fn decrypt_attachment(
encrypted: &[u8],
master_key: &Zeroizing<[u8; 32]>,
) -> Result<Zeroizing<Vec<u8>>> {
let plaintext = decrypt(master_key, encrypted)?;
Ok(Zeroizing::new(plaintext))
}
#[cfg(test)]
mod crypto_tests {
use super::*;
fn key() -> Zeroizing<[u8; 32]> {
Zeroizing::new([0x42u8; 32])
}
#[test]
fn attachment_round_trip() {
let plaintext = b"the quick brown fox jumps over the lazy dog";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
let dec = decrypt_attachment(&enc.bytes, &key()).unwrap();
assert_eq!(dec.as_slice(), plaintext);
}
#[test]
fn attachment_id_matches_sha256() {
let plaintext = b"hello world";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext));
}
#[test]
fn oversize_attachment_rejected() {
let plaintext = vec![0u8; 11_000_000];
let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024);
assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. })));
}
#[test]
fn wrong_key_fails_with_opaque_decrypt() {
let plaintext = b"x";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
let wrong = Zeroizing::new([0u8; 32]);
let err = decrypt_attachment(&enc.bytes, &wrong);
assert!(matches!(err, Err(RelicarioError::Decrypt)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attachment_ref_round_trip() {
let r = AttachmentRef {
id: AttachmentId("0123456789abcdef".into()),
filename: "doc.pdf".into(),
mime_type: "application/pdf".into(),
size: 12345,
created: 1_700_000_000,
};
let json = serde_json::to_string(&r).unwrap();
let parsed: AttachmentRef = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.filename, "doc.pdf");
assert_eq!(parsed.size, 12345);
}
#[test]
fn attachment_summary_from_ref() {
let r = AttachmentRef {
id: AttachmentId("aabb".into()),
filename: "x.txt".into(),
mime_type: "text/plain".into(),
size: 5,
created: 0,
};
let s: AttachmentSummary = (&r).into();
assert_eq!(s.filename, "x.txt");
assert_eq!(s.id, r.id);
}
}

View File

@@ -0,0 +1,420 @@
//! Argon2id key derivation and XChaCha20-Poly1305 authenticated encryption.
//!
//! This module implements the low-level "encrypt bytes / decrypt bytes" layer.
//! Higher-level typed wrappers (encrypt_entry, encrypt_manifest) live in [`crate::vault`].
//!
//! ## Why XChaCha20-Poly1305 over AES-GCM
//!
//! - **192-bit nonce** (vs. 96-bit for AES-GCM): eliminates nonce collision risk
//! even with random nonces across billions of encryptions. With AES-GCM's 96-bit
//! nonce, birthday-bound collisions become probable around 2^48 messages under
//! the same key -- a real concern for a long-lived vault.
//! - **Fast on WASM and ARM without AES-NI**: ChaCha20 is a pure arithmetic cipher
//! (add/rotate/XOR) with no dependency on hardware AES acceleration. AES-GCM is
//! fast *only* with AES-NI; without it, software AES is both slow and vulnerable
//! to cache-timing side channels.
//!
//! ## Binary ciphertext format
//!
//! Every encrypted blob produced by [`encrypt`] has this layout:
//!
//! ```text
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
//! ```
//!
//! - **Version byte** (`0x02`): allows future format changes without ambiguity.
//! Decryption rejects any version it does not recognize.
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
//! nonce management.
//! - **Ciphertext + tag**: the AEAD output. The Poly1305 tag (16 bytes) is
//! appended by the cipher implementation; we do not separate it.
//!
//! ## KDF pipeline
//!
//! [`derive_master_key`] concatenates the passphrase and image_secret as a single
//! password input to Argon2id:
//!
//! ```text
//! password = passphrase_bytes || image_secret (32 bytes)
//! master_key = Argon2id(password, salt, params) -> 32 bytes
//! ```
//!
//! Both factors contribute to the derived key -- compromising one without the
//! other is insufficient. The salt is vault-specific and stored in `.relicario/salt`.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
/// Current binary format version. Increment this if the ciphertext layout changes.
pub const VERSION_BYTE: u8 = 0x02;
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
const NONCE_LEN: usize = 24;
/// Poly1305 authentication tag length: 128 bits = 16 bytes.
/// Used only for minimum-length validation during decryption.
const TAG_LEN: usize = 16;
/// Total header size: version byte + nonce. The ciphertext (including tag)
/// follows immediately after the header.
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
///
/// Returns the binary blob in the format: `version(1) || nonce(24) || ciphertext+tag`.
/// A fresh random nonce is generated for each call via the OS CSPRNG.
///
/// # Errors
///
/// Returns [`RelicarioError::Encrypt`] if the underlying AEAD operation fails
/// (extremely unlikely in practice).
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
// Generate a fresh random 24-byte nonce for every encryption.
// With 192 bits of randomness, nonce reuse probability is negligible
// even across billions of encryptions under the same key.
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
// Output: version(1) || nonce(24) || ciphertext+tag
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
output.push(VERSION_BYTE);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
/// Decrypt a blob produced by [`encrypt`], returning the original plaintext.
///
/// Validates the version byte and minimum blob length before attempting
/// authenticated decryption. If the key is wrong or the data has been
/// tampered with, the Poly1305 tag verification fails and [`RelicarioError::Decrypt`]
/// is returned -- with no information about which bytes were wrong (preventing
/// padding oracle / chosen-ciphertext attacks).
///
/// # Errors
///
/// - [`RelicarioError::Format`] if the data is too short or has an unknown version byte.
/// - [`RelicarioError::Decrypt`] if the AEAD tag verification fails (wrong key or
/// tampered data).
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
// Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes.
// A zero-length plaintext produces exactly 41 bytes of output.
if data.len() < HEADER_LEN + TAG_LEN {
return Err(RelicarioError::Format(
"data too short to be valid ciphertext".into(),
));
}
let found = data[0];
if found != VERSION_BYTE {
return Err(RelicarioError::UnsupportedFormatVersion {
found,
expected: VERSION_BYTE,
});
}
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
let ciphertext = &data[HEADER_LEN..];
let cipher = XChaCha20Poly1305::new(key.into());
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
Ok(plaintext)
}
/// Tunable parameters for the Argon2id key derivation function.
///
/// These are stored in the vault's `.relicario/params.json` so that every client
/// derives the same master key from the same inputs. Making them configurable
/// lets tests use fast params (m=256, t=1, p=1) while production uses strong
/// params (m=64MiB, t=3, p=4).
///
/// The parameters follow Argon2id naming conventions:
/// - `argon2_m`: memory cost in KiB
/// - `argon2_t`: time cost (number of iterations)
/// - `argon2_p`: parallelism degree (number of lanes)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams {
/// Memory cost in KiB. Default is 65536 (64 MiB), which makes GPU/ASIC
/// brute-force attacks expensive. Tests use 256 KiB for speed.
pub argon2_m: u32,
/// Time cost (iteration count). Default is 3. Higher values increase CPU
/// time linearly. Combined with high memory cost, this makes each key
/// derivation take ~1 second on modern hardware.
pub argon2_t: u32,
/// Parallelism degree. Default is 4. Sets the number of independent lanes
/// in the Argon2id memory-hard computation.
pub argon2_p: u32,
}
/// Production-strength default parameters: 64 MiB memory, 3 iterations, 4 lanes.
///
/// These are calibrated to take roughly 0.5-1 second on a modern desktop CPU,
/// making brute-force attacks impractical while keeping interactive unlock fast
/// enough for daily use.
impl Default for KdfParams {
fn default() -> Self {
Self {
argon2_m: 65536,
argon2_t: 3,
argon2_p: 4,
}
}
}
/// Derive a 256-bit master key from the user's passphrase and reference image secret.
///
/// The two factors (passphrase + image_secret) are concatenated into a single
/// password input to Argon2id. This means both factors contribute entropy to
/// the derived key -- compromising one factor alone is insufficient.
///
/// # Arguments
///
/// - `passphrase`: the user's passphrase as raw UTF-8 bytes.
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via
/// [`crate::imgsecret::extract`].
/// - `salt`: a 32-byte vault-specific salt (stored in `.relicario/salt`).
/// - `params`: the Argon2id tuning parameters (stored in `.relicario/params.json`).
///
/// # Returns
///
/// A 32-byte master key suitable for use with [`encrypt`] and [`decrypt`].
///
/// # Errors
///
/// Returns [`RelicarioError::Kdf`] if the Argon2id parameters are invalid (e.g.,
/// memory cost below the library's minimum).
pub fn derive_master_key(
passphrase: &[u8],
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(
params.argon2_m,
params.argon2_t,
params.argon2_p,
Some(32),
)
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
// Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
Ok(s) => s.nfc().collect::<String>().into_bytes(),
Err(_) => passphrase.to_vec(),
};
// Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
// [u64_be(32)][image_secret]
// Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
password.extend_from_slice(&nfc_passphrase);
password.extend_from_slice(&32u64.to_be_bytes());
password.extend_from_slice(image_secret);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(password.as_slice(), salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
fn fast_params() -> KdfParams {
KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
}
}
#[test]
fn derive_master_key_deterministic() {
let passphrase = b"test-passphrase";
let image_secret = [0x42u8; 32];
let salt = [0x01u8; 32];
let params = fast_params();
let key1 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
assert_eq!(*key1, *key2);
}
#[test]
fn derive_master_key_different_passphrase() {
let image_secret = [0x42u8; 32];
let salt = [0x01u8; 32];
let params = fast_params();
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, &params).unwrap();
assert_ne!(*key1, *key2);
}
#[test]
fn derive_master_key_different_image_secret() {
let passphrase = b"test-passphrase";
let salt = [0x01u8; 32];
let params = fast_params();
let image_secret1 = [0x11u8; 32];
let image_secret2 = [0x22u8; 32];
let key1 = derive_master_key(passphrase, &image_secret1, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret2, &salt, &params).unwrap();
assert_ne!(*key1, *key2);
}
#[test]
fn encrypt_decrypt_round_trip() {
let key = [0xABu8; 32];
let plaintext = b"hello, idfoto!";
let ciphertext = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn decrypt_wrong_key_fails() {
let key = [0xABu8; 32];
let wrong_key = [0xCDu8; 32];
let plaintext = b"sensitive data";
let ciphertext = encrypt(&key, plaintext).unwrap();
let result = decrypt(&wrong_key, &ciphertext);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), RelicarioError::Decrypt));
}
#[test]
fn decrypt_tampered_data_fails() {
let key = [0xABu8; 32];
let plaintext = b"sensitive data";
let mut ciphertext = encrypt(&key, plaintext).unwrap();
// Flip a byte in the ciphertext portion (after header)
let flip_pos = HEADER_LEN + 2;
ciphertext[flip_pos] ^= 0xFF;
let result = decrypt(&key, &ciphertext);
assert!(result.is_err());
}
#[test]
fn ciphertext_format_has_correct_structure() {
let key = [0x11u8; 32];
let plaintext = b"test plaintext for structure check";
let ciphertext = encrypt(&key, plaintext).unwrap();
// Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag)
let expected_len = 1 + 24 + plaintext.len() + 16;
assert_eq!(ciphertext.len(), expected_len);
// Version byte must be 0x02
assert_eq!(ciphertext[0], 0x02);
}
#[test]
fn length_prefix_eliminates_concatenation_ambiguity() {
// Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide.
// With length-prefix: distinct inputs always yield distinct keys.
let salt = [0u8; 32];
let params = fast_params();
// Pair A: passphrase "abc", image_secret starts with 0x44
let mut img_a = [0u8; 32]; img_a[0] = 0x44;
let key_a = derive_master_key(b"abc", &img_a, &salt, &params).unwrap();
// Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1
let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image
let key_b = derive_master_key(b"abcD", &img_b, &salt, &params).unwrap();
// With length-prefix, the keys MUST differ.
assert_ne!(*key_a, *key_b);
}
#[test]
fn nfc_normalization_collapses_unicode_forms() {
// "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301).
// Both must produce the same key after NFC normalization.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let nfc = "caf\u{00e9}".as_bytes(); // é precomposed
let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute
let key_nfc = derive_master_key(nfc, &img, &salt, &params).unwrap();
let key_nfd = derive_master_key(nfd, &img, &salt, &params).unwrap();
assert_eq!(*key_nfc, *key_nfd);
}
#[test]
fn master_key_is_zeroized_on_drop() {
// Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if
// we wrap correctly. The drop wipe is verified by the zeroize crate's tests.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, &params).unwrap();
assert_eq!(key.len(), 32);
}
#[test]
fn version_byte_is_0x02() {
assert_eq!(VERSION_BYTE, 0x02);
}
#[test]
fn decrypt_rejects_v1_blob_with_typed_error() {
// Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes].
let mut blob = vec![0x01u8];
blob.extend_from_slice(&[0u8; 24]);
blob.extend_from_slice(&[0u8; 16]);
let key = Zeroizing::new([0u8; 32]);
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
match err {
RelicarioError::UnsupportedFormatVersion { found, expected } => {
assert_eq!(found, 0x01);
assert_eq!(expected, 0x02);
}
other => panic!("expected UnsupportedFormatVersion, got {:?}", other),
}
}
}

View File

@@ -0,0 +1,108 @@
//! Unified error type for the relicario-core crate.
//!
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
//! for `std::result::Result<T, RelicarioError>`. Using a single error enum keeps the
//! public API surface predictable and makes error handling in callers (CLI, WASM
//! bindings, mobile FFI) straightforward.
use thiserror::Error;
/// All errors that can originate from relicario-core operations.
///
/// Variants are ordered roughly by the pipeline stage where they occur:
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
/// steganography -> serialization -> device keys.
#[derive(Debug, Error)]
pub enum RelicarioError {
#[error("key derivation failed: {0}")]
Kdf(String),
#[error("encryption failed: {0}")]
Encrypt(String),
/// Authenticated decryption failed. Message intentionally opaque (audit M4).
#[error("decryption failed")]
Decrypt,
#[error("invalid vault format: {0}")]
Format(String),
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
UnsupportedFormatVersion { found: u8, expected: u8 },
/// An item was looked up by ID but does not exist in the manifest.
#[error("item not found: {0}")]
ItemNotFound(String),
/// A passphrase failed the strength gate at vault creation (audit H3).
#[error("passphrase strength insufficient (score {score}/4)")]
WeakPassphrase { score: u8 },
/// An attachment exceeded the per-attachment cap from VaultSettings.
#[error("attachment too large: {size} bytes > {max} bytes max")]
AttachmentTooLarge { size: u64, max: u64 },
#[error("imgsecret: {0}")]
ImgSecret(String),
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
ImageTooSmall {
min_width: u32,
min_height: u32,
actual_width: u32,
actual_height: u32,
},
#[error("extraction failed: no valid secret found in image")]
ExtractionFailed,
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("device key error: {0}")]
DeviceKey(String),
}
/// Crate-wide result alias, reducing boilerplate in function signatures.
pub type Result<T> = std::result::Result<T, RelicarioError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decrypt_error_message_is_opaque() {
let err = RelicarioError::Decrypt;
assert_eq!(format!("{}", err), "decryption failed");
}
#[test]
fn weak_passphrase_carries_score() {
let err = RelicarioError::WeakPassphrase { score: 1 };
let s = format!("{}", err);
assert!(s.contains("passphrase"));
assert!(s.contains("strength"));
}
#[test]
fn attachment_too_large_reports_sizes() {
let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 };
let s = format!("{}", err);
assert!(s.contains("11000000"));
assert!(s.contains("10485760"));
}
#[test]
fn item_not_found_carries_id() {
let err = RelicarioError::ItemNotFound("abc123".to_string());
assert!(format!("{}", err).contains("abc123"));
}
#[test]
fn unsupported_format_version_reports_byte() {
let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 };
let s = format!("{}", err);
assert!(s.contains("01") || s.contains("1"));
assert!(s.contains("02") || s.contains("2"));
}
}

View File

@@ -0,0 +1,269 @@
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
//! eliminate modulo bias. Strength rating via zxcvbn.
use bip39::{Language, Mnemonic};
use rand::distributions::{Distribution, Uniform};
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset};
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?.";
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS: &[u8] = b"0123456789";
pub fn generate_password(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
match req {
GeneratorRequest::Random { length, classes, symbol_charset } => {
random_password(*length, classes, symbol_charset)
}
GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format(
"use generate_passphrase() for BIP39 requests".into(),
)),
}
}
fn random_password(
length: u32,
classes: &CharClasses,
symbol_charset: &SymbolCharset,
) -> Result<Zeroizing<String>> {
if length == 0 || length > 128 {
return Err(RelicarioError::Format("length must be 1..=128".into()));
}
let mut charset: Vec<u8> = Vec::new();
if classes.lower { charset.extend_from_slice(LOWER); }
if classes.upper { charset.extend_from_slice(UPPER); }
if classes.digits { charset.extend_from_slice(DIGITS); }
if classes.symbols {
let symbols: &[u8] = match symbol_charset {
SymbolCharset::SafeOnly => SAFE_SYMBOLS,
SymbolCharset::Extended => EXTENDED_SYMBOLS,
SymbolCharset::Custom(s) => {
if !s.is_ascii() {
return Err(RelicarioError::Format(
"SymbolCharset::Custom must be ASCII-only".into(),
));
}
s.as_bytes()
}
};
charset.extend_from_slice(symbols);
}
if charset.is_empty() {
return Err(RelicarioError::Format("at least one character class required".into()));
}
let dist = Uniform::from(0..charset.len());
let mut rng = OsRng;
let bytes: Vec<u8> = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect();
Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset")))
}
pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
match req {
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
bip39_passphrase(*word_count, separator, *capitalization)
}
GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
"use generate_password() for Random requests".into(),
)),
}
}
fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
if !matches!(word_count, 3..=12) {
return Err(RelicarioError::Format("word_count must be 3..=12".into()));
}
// bip39 v2 requires entropy 128256 bits in multiples of 32 bits (4 bytes).
// We always generate 128 bits (16 bytes) → 12 words, then take the first
// word_count words. This gives full-entropy sourcing even for short passphrases.
let mut entropy = Zeroizing::new([0u8; 16]);
OsRng.fill_bytes(entropy.as_mut_slice());
let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice())
.map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?;
let words: Vec<String> = m.words().take(word_count as usize).map(|w| {
match cap {
Capitalization::Lower => w.to_ascii_lowercase(),
Capitalization::Upper => w.to_ascii_uppercase(),
Capitalization::FirstOfEach | Capitalization::Title => {
let mut chars = w.chars();
chars.next().map(|c| c.to_ascii_uppercase().to_string())
.unwrap_or_default() + chars.as_str()
}
Capitalization::Mixed => {
w.chars().enumerate().map(|(i, c)| {
if i % 2 == 0 { c.to_ascii_uppercase() } else { c }
}).collect()
}
}
}).collect();
Ok(Zeroizing::new(words.join(separator)))
}
/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses.
#[derive(Debug, Clone, Copy)]
pub struct StrengthEstimate {
pub score: u8,
pub guesses_log10: f64,
}
pub fn rate_passphrase(p: &str) -> StrengthEstimate {
let est = zxcvbn::zxcvbn(p, &[]);
StrengthEstimate {
score: est.score().into(),
guesses_log10: est.guesses_log10(),
}
}
/// Strength gate at vault creation (audit H3): require score >= 3.
pub fn validate_passphrase_strength(p: &str) -> Result<()> {
let est = rate_passphrase(p);
if est.score < 3 {
return Err(RelicarioError::WeakPassphrase { score: est.score });
}
Ok(())
}
#[cfg(test)]
mod bip39_tests {
use super::*;
#[test]
fn bip39_default_is_5_space_separated_words() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert_eq!(pw.split(' ').count(), 5);
}
#[test]
fn bip39_dash_separator() {
let req = GeneratorRequest::Bip39 {
word_count: 4,
separator: "-".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert_eq!(pw.split('-').count(), 4);
assert!(!pw.contains(' '));
}
#[test]
fn bip39_first_of_each_capitalizes() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::FirstOfEach,
};
let pw = generate_passphrase(&req).unwrap();
for word in pw.split(' ') {
let first = word.chars().next().unwrap();
assert!(first.is_ascii_uppercase(), "word {word} should start uppercase");
}
}
#[test]
fn bip39_rejects_bad_word_count() {
let req = GeneratorRequest::Bip39 {
word_count: 2,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
assert!(generate_passphrase(&req).is_err());
}
#[test]
fn rate_passphrase_strong_one_passes_gate() {
// 6-word bip39 passphrase
let req = GeneratorRequest::Bip39 {
word_count: 6,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert!(validate_passphrase_strength(&pw).is_ok());
}
#[test]
fn rate_passphrase_weak_fails_gate() {
assert!(validate_passphrase_strength("password").is_err());
assert!(validate_passphrase_strength("12345678").is_err());
assert!(validate_passphrase_strength("hunter2").is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_default_password_is_20_chars() {
let req = GeneratorRequest::default();
let pw = generate_password(&req).unwrap();
assert_eq!(pw.len(), 20);
}
#[test]
fn rejects_zero_length() {
let req = GeneratorRequest::Random {
length: 0,
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
assert!(generate_password(&req).is_err());
}
#[test]
fn rejects_no_classes() {
let req = GeneratorRequest::Random {
length: 8,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
assert!(generate_password(&req).is_err());
}
#[test]
fn lower_only_password_uses_lowercase() {
let req = GeneratorRequest::Random {
length: 100,
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
let pw = generate_password(&req).unwrap();
assert!(pw.chars().all(|c| c.is_ascii_lowercase()));
}
#[test]
fn safe_symbols_excludes_quotes_and_brackets() {
let req = GeneratorRequest::Random {
length: 128,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
};
let pw = generate_password(&req).unwrap();
for c in pw.chars() {
assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'),
"safe charset must not include {c}");
}
}
#[test]
fn custom_charset_rejects_non_ascii() {
let req = GeneratorRequest::Random {
length: 8,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
symbol_charset: SymbolCharset::Custom("ñé".into()),
};
let err = generate_password(&req);
assert!(err.is_err(), "non-ASCII custom charset must be rejected");
}
}

View File

@@ -0,0 +1,124 @@
//! Random and content-addressed identifiers for items, fields, and attachments.
//!
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ItemId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FieldId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AttachmentId(pub String);
impl ItemId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for ItemId {
fn default() -> Self { Self::new() }
}
impl FieldId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for FieldId {
fn default() -> Self { Self::new() }
}
impl AttachmentId {
pub fn from_plaintext(plaintext: &[u8]) -> Self {
let digest = Sha256::digest(plaintext);
Self(hex::encode(&digest[..8]))
}
pub fn as_str(&self) -> &str { &self.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_id_is_16_hex_chars() {
let id = ItemId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn item_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..10_000 {
assert!(seen.insert(ItemId::new().0));
}
}
#[test]
fn field_id_is_16_hex_chars() {
let id = FieldId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn field_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..10_000 {
assert!(seen.insert(FieldId::new().0));
}
}
#[test]
fn attachment_id_is_deterministic() {
let plaintext = b"hello world";
let a = AttachmentId::from_plaintext(plaintext);
let b = AttachmentId::from_plaintext(plaintext);
assert_eq!(a, b);
}
#[test]
fn attachment_id_changes_with_plaintext() {
let a = AttachmentId::from_plaintext(b"hello");
let b = AttachmentId::from_plaintext(b"world");
assert_ne!(a, b);
}
#[test]
fn attachment_id_is_16_hex_chars() {
let id = AttachmentId::from_plaintext(b"any bytes");
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn ids_serialize_as_bare_strings() {
let item = ItemId("abcdef0123456789".to_string());
let json = serde_json::to_string(&item).unwrap();
assert_eq!(json, "\"abcdef0123456789\"");
let parsed: ItemId = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, item);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
//! Item envelope, sections, and custom fields.
//!
//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing
//! to a single tagged enum) so the kind can be queried without inspecting the value.
//! Validation invariant: kind and value's discriminants must match — enforced at
//! construction (`Field::new`) and during deserialization (`Field::validate`).
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::ids::{AttachmentId, FieldId};
use crate::item_types::TotpConfig;
use crate::time::MonthYear;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
Text,
Multiline,
Password,
Concealed,
Url,
Email,
Phone,
Date,
MonthYear,
Totp,
Reference,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum FieldValue {
Text(String),
Multiline(String),
Password(Zeroizing<String>),
Concealed(Zeroizing<String>),
Url(Url),
Email(String),
Phone(String),
Date(NaiveDate),
MonthYear(MonthYear),
Totp(TotpConfig),
Reference(AttachmentId),
}
impl FieldValue {
pub fn kind(&self) -> FieldKind {
match self {
FieldValue::Text(_) => FieldKind::Text,
FieldValue::Multiline(_) => FieldKind::Multiline,
FieldValue::Password(_) => FieldKind::Password,
FieldValue::Concealed(_) => FieldKind::Concealed,
FieldValue::Url(_) => FieldKind::Url,
FieldValue::Email(_) => FieldKind::Email,
FieldValue::Phone(_) => FieldKind::Phone,
FieldValue::Date(_) => FieldKind::Date,
FieldValue::MonthYear(_) => FieldKind::MonthYear,
FieldValue::Totp(_) => FieldKind::Totp,
FieldValue::Reference(_) => FieldKind::Reference,
}
}
/// True if this kind triggers field-history capture on update.
pub fn is_history_tracked(&self) -> bool {
matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Field {
pub id: FieldId,
pub label: String,
pub kind: FieldKind,
pub value: FieldValue,
#[serde(default)]
pub hidden_by_default: bool,
}
impl Field {
/// Construct a field, deriving `kind` from `value`.
pub fn new(label: String, value: FieldValue) -> Self {
let kind = value.kind();
Self {
id: FieldId::new(),
label,
kind,
value,
hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed),
}
}
/// Verify kind/value discriminants match. Called after deserialization.
pub fn validate(&self) -> Result<()> {
if self.kind != self.value.kind() {
return Err(RelicarioError::Format(format!(
"field {}: kind {:?} does not match value discriminant {:?}",
self.id.as_str(),
self.kind,
self.value.kind()
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Section {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub fields: Vec<Field>,
}
use std::collections::HashMap;
use crate::attachment::AttachmentRef;
use crate::ids::ItemId;
use crate::item_types::{ItemCore, ItemType};
use crate::time::now_unix;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldHistoryEntry {
pub value: Zeroizing<String>,
pub replaced_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub id: ItemId,
pub title: String,
pub r#type: ItemType,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created: i64,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
pub core: ItemCore,
#[serde(default)]
pub sections: Vec<Section>,
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
#[serde(default)]
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
}
impl Item {
/// Construct a new Item from a typed core; auto-fills id, type, timestamps.
pub fn new(title: String, core: ItemCore) -> Self {
let now = now_unix();
let r#type = core.item_type();
Self {
id: ItemId::new(),
title,
r#type,
tags: Vec::new(),
favorite: false,
group: None,
notes: None,
created: now,
modified: now,
trashed_at: None,
core,
sections: Vec::new(),
attachments: Vec::new(),
field_history: HashMap::new(),
}
}
/// Replace a custom field's value, capturing the previous value into
/// field_history if the field's kind is history-tracked.
pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> {
for section in &mut self.sections {
if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) {
if field.value.kind() != new_value.kind() {
return Err(RelicarioError::Format(format!(
"field {}: cannot change kind from {:?} to {:?}",
field.id.as_str(), field.value.kind(), new_value.kind()
)));
}
if field.value.is_history_tracked() {
let serialized = serialize_history_value(&field.value)?;
self.field_history
.entry(field.id.clone())
.or_default()
.push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() });
}
field.value = new_value;
self.modified = now_unix();
return Ok(());
}
}
Err(RelicarioError::Format(format!("field {} not found", field_id.as_str())))
}
pub fn soft_delete(&mut self) {
self.trashed_at = Some(now_unix());
self.modified = now_unix();
}
pub fn restore(&mut self) {
self.trashed_at = None;
self.modified = now_unix();
}
pub fn is_trashed(&self) -> bool {
self.trashed_at.is_some()
}
pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) {
use crate::settings::HistoryRetention;
for history in self.field_history.values_mut() {
match retention {
HistoryRetention::Forever => {}
HistoryRetention::LastN(n) => {
let n = *n as usize;
if history.len() > n {
let drop_count = history.len() - n;
history.drain(..drop_count);
}
}
HistoryRetention::Days(d) => {
let cutoff = now - (*d as i64) * 86_400;
history.retain(|e| e.replaced_at > cutoff);
}
}
}
}
}
/// Serialize a FieldValue to the string form stored in field_history.
fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
let s = match value {
FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()),
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
FieldValue::Totp(cfg) => {
// Store the base32-encoded secret string for human-recognizability.
let s = base32_encode(&cfg.secret);
Zeroizing::new(s)
}
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
};
Ok(s)
}
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
fn base32_encode(bytes: &[u8]) -> String {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut out = String::new();
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &b in bytes {
buffer = (buffer << 8) | (b as u32);
bits += 8;
while bits >= 5 {
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
bits -= 5;
}
}
if bits > 0 {
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_value_kind_matches() {
let v = FieldValue::Text("hello".into());
assert_eq!(v.kind(), FieldKind::Text);
}
#[test]
fn password_field_marked_history_tracked() {
assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked());
assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked());
assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked());
assert!(!FieldValue::Text("x".into()).is_history_tracked());
assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked());
}
#[test]
fn field_new_derives_kind_from_value() {
let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into())));
assert_eq!(f.kind, FieldKind::Password);
assert!(f.hidden_by_default);
}
#[test]
fn field_new_text_not_hidden() {
let f = Field::new("Username".into(), FieldValue::Text("alice".into()));
assert!(!f.hidden_by_default);
}
#[test]
fn field_validate_catches_kind_value_mismatch() {
let f = Field {
id: FieldId::new(),
label: "x".into(),
kind: FieldKind::Password,
value: FieldValue::Text("not actually a password".into()),
hidden_by_default: false,
};
assert!(f.validate().is_err());
}
#[test]
fn field_round_trips() {
let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into())));
let json = serde_json::to_string(&f).unwrap();
let parsed: Field = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.label, "Recovery code");
assert_eq!(parsed.kind, FieldKind::Concealed);
parsed.validate().unwrap();
}
#[test]
fn section_round_trip() {
let s = Section {
name: Some("Recovery codes".into()),
fields: vec![
Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))),
Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))),
],
};
let json = serde_json::to_string(&s).unwrap();
let parsed: Section = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
assert_eq!(parsed.fields.len(), 2);
}
#[test]
fn new_item_has_timestamps_and_id() {
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
let item = Item::new("note".into(), core);
assert_eq!(item.id.0.len(), 16);
assert_eq!(item.r#type, ItemType::SecureNote);
assert!(item.created > 0);
assert_eq!(item.created, item.modified);
assert!(item.field_history.is_empty());
}
#[test]
fn soft_delete_and_restore_round_trip() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
assert!(!item.is_trashed());
item.soft_delete();
assert!(item.is_trashed());
item.restore();
assert!(!item.is_trashed());
}
#[test]
fn set_field_value_captures_history_for_password() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into())));
let pw_id = pw_field.id.clone();
item.sections.push(Section { name: None, fields: vec![pw_field] });
item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap();
let hist = item.field_history.get(&pw_id).expect("history should exist");
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].value.as_str(), "old");
}
#[test]
fn set_field_value_does_not_capture_history_for_text() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let f = Field::new("nickname".into(), FieldValue::Text("a".into()));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap();
assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty()));
}
#[test]
fn set_field_value_rejects_kind_change() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let f = Field::new("x".into(), FieldValue::Text("a".into()));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into())));
assert!(err.is_err());
}
#[test]
fn item_serializes_with_minimal_optional_fields() {
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
let item = Item::new("note".into(), core);
let json = serde_json::to_string(&item).unwrap();
// No "trashed_at" or "group" or "notes" should appear when None
assert!(!json.contains("trashed_at"));
assert!(!json.contains("\"group\""));
}
#[test]
fn full_item_round_trip() {
let core = ItemCore::Login(crate::item_types::LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com").unwrap()),
totp: None,
});
let mut item = Item::new("GitHub".into(), core);
item.tags = vec!["work".into()];
item.favorite = true;
item.notes = Some("notes".into());
let json = serde_json::to_string(&item).unwrap();
let parsed: Item = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.title, "GitHub");
assert_eq!(parsed.tags, vec!["work".to_string()]);
assert!(parsed.favorite);
match parsed.core {
ItemCore::Login(l) => {
assert_eq!(l.username.as_deref(), Some("alice"));
}
other => panic!("expected Login, got {:?}", other),
}
}
#[test]
fn prune_history_keeps_last_n() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
for i in 1..=5 {
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
}
assert_eq!(item.field_history[&fid].len(), 5);
item.prune_history(&HistoryRetention::LastN(3), 0);
assert_eq!(item.field_history[&fid].len(), 3);
// Keeps the MOST RECENT 3
assert_eq!(item.field_history[&fid][0].value.as_str(), "v2");
}
#[test]
fn prune_history_drops_old_entries_by_days() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
let now = 1_000_000_000;
item.field_history.insert(fid.clone(), vec![
FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 },
FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 },
]);
item.prune_history(&HistoryRetention::Days(30), now);
assert_eq!(item.field_history[&fid].len(), 1);
assert_eq!(item.field_history[&fid][0].value.as_str(), "recent");
}
#[test]
fn prune_history_forever_keeps_all() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
item.field_history.insert(FieldId::new(), vec![
FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 },
FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 },
]);
item.prune_history(&HistoryRetention::Forever, 1_000_000_000);
assert_eq!(item.field_history.values().next().unwrap().len(), 2);
}
}

View File

@@ -0,0 +1,68 @@
//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::time::MonthYear;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CardCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub number: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub holder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<MonthYear>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cvv: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pin: Option<Zeroizing<String>>,
#[serde(default)]
pub kind: CardKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CardKind {
#[default]
Credit,
Debit,
Gift,
Loyalty,
Other,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn card_full_round_trip() {
let card = CardCore {
number: Some(Zeroizing::new("4111111111111111".into())),
holder: Some("Alice Doe".into()),
expiry: Some(MonthYear::new(12, 2030).unwrap()),
cvv: Some(Zeroizing::new("123".into())),
pin: Some(Zeroizing::new("0000".into())),
kind: CardKind::Credit,
};
let json = serde_json::to_string(&card).unwrap();
let parsed: CardCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.holder.as_deref(), Some("Alice Doe"));
assert_eq!(parsed.kind, CardKind::Credit);
assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap()));
}
#[test]
fn card_kind_default_is_credit() {
let json = "{}";
let card: CardCore = serde_json::from_str(json).unwrap();
assert_eq!(card.kind, CardKind::Credit);
}
#[test]
fn card_kind_serializes_snake_case() {
let json = serde_json::to_string(&CardKind::Loyalty).unwrap();
assert_eq!(json, "\"loyalty\"");
}
}

View File

@@ -0,0 +1,40 @@
//! Document: filename + mime + pointer to the primary attachment blob.
use serde::{Deserialize, Serialize};
use crate::ids::AttachmentId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentCore {
pub filename: String,
pub mime_type: String,
pub primary_attachment: AttachmentId,
}
impl Default for DocumentCore {
fn default() -> Self {
Self {
filename: String::new(),
mime_type: "application/octet-stream".into(),
primary_attachment: AttachmentId(String::new()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_round_trip() {
let doc = DocumentCore {
filename: "passport.pdf".into(),
mime_type: "application/pdf".into(),
primary_attachment: AttachmentId("0123456789abcdef".into()),
};
let json = serde_json::to_string(&doc).unwrap();
let parsed: DocumentCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.filename, "passport.pdf");
assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef");
}
}

View File

@@ -0,0 +1,45 @@
//! Identity: name, address, phone, email, date-of-birth.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdentityCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<NaiveDate>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_full_round_trip() {
let id = IdentityCore {
full_name: Some("Alice Doe".into()),
address: Some("123 Main St\nAnytown".into()),
phone: Some("+1-555-0100".into()),
email: Some("alice@example.com".into()),
date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18),
};
let json = serde_json::to_string(&id).unwrap();
let parsed: IdentityCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe"));
assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18));
}
#[test]
fn empty_identity_omits_all_fields() {
let id = IdentityCore::default();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "{}");
}
}

View File

@@ -0,0 +1,42 @@
//! Key: arbitrary key material (Zeroizing), label, public key, algorithm.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeyCore {
pub key_material: Zeroizing<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub algorithm: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_round_trip() {
let k = KeyCore {
key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()),
label: Some("yubikey-backup".into()),
public_key: Some("ssh-ed25519 AAAAC3...".into()),
algorithm: Some("ed25519".into()),
};
let json = serde_json::to_string(&k).unwrap();
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
assert!(parsed.key_material.starts_with("-----BEGIN"));
assert_eq!(parsed.algorithm.as_deref(), Some("ed25519"));
}
#[test]
fn empty_key_material_round_trips() {
let k = KeyCore::default();
let json = serde_json::to_string(&k).unwrap();
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
assert!(parsed.key_material.is_empty());
}
}

View File

@@ -0,0 +1,63 @@
//! Login item core: username, password (Zeroizing), URL, optional TOTP.
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::item_types::TotpConfig;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoginCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp: Option<TotpConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_login_round_trips() {
let login = LoginCore::default();
let json = serde_json::to_string(&login).unwrap();
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
assert!(parsed.username.is_none());
assert!(parsed.password.is_none());
}
#[test]
fn full_login_round_trips() {
let login = LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com/login").unwrap()),
totp: None,
};
let json = serde_json::to_string(&login).unwrap();
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.username.as_deref(), Some("alice"));
assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2"));
assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login"));
}
#[test]
fn omitted_fields_dont_appear_in_json() {
let login = LoginCore {
username: Some("alice".into()),
password: None,
url: None,
totp: None,
};
let json = serde_json::to_string(&login).unwrap();
assert!(!json.contains("password"));
assert!(!json.contains("url"));
assert!(!json.contains("totp"));
assert!(json.contains("alice"));
}
}

View File

@@ -0,0 +1,127 @@
//! Per-type "core" structs for typed items.
//!
//! Each variant lives in its own submodule. The `ItemCore` enum + match
//! exhaustiveness is the extension mechanism — adding a new variant later
//! means: create the submodule, add the enum variant, fix the match arms
//! the compiler points at, register the popup form (Plan 1C).
use serde::{Deserialize, Serialize};
pub mod login;
pub mod secure_note;
pub mod identity;
pub mod card;
pub mod key;
pub mod document;
pub mod totp;
pub use login::LoginCore;
pub use secure_note::SecureNoteCore;
pub use identity::IdentityCore;
pub use card::{CardCore, CardKind};
pub use key::KeyCore;
pub use document::DocumentCore;
pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
Login,
SecureNote,
Identity,
Card,
Key,
Document,
Totp,
}
// INVARIANT: no *Core struct may have a field serialized as "type" —
// that key is reserved for serde's internal tag. Use "kind" for
// type-discriminant fields within core structs (CardKind, TotpKind).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ItemCore {
Login(LoginCore),
SecureNote(SecureNoteCore),
Identity(IdentityCore),
Card(CardCore),
Key(KeyCore),
Document(DocumentCore),
Totp(TotpCore),
}
impl ItemCore {
pub fn item_type(&self) -> ItemType {
match self {
ItemCore::Login(_) => ItemType::Login,
ItemCore::SecureNote(_) => ItemType::SecureNote,
ItemCore::Identity(_) => ItemType::Identity,
ItemCore::Card(_) => ItemType::Card,
ItemCore::Key(_) => ItemType::Key,
ItemCore::Document(_) => ItemType::Document,
ItemCore::Totp(_) => ItemType::Totp,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_type_serializes_snake_case() {
let json = serde_json::to_string(&ItemType::SecureNote).unwrap();
assert_eq!(json, "\"secure_note\"");
}
#[test]
fn item_core_login_round_trip_via_tag() {
use zeroize::Zeroizing;
let core = ItemCore::Login(LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: None,
totp: None,
});
let json = serde_json::to_string(&core).unwrap();
// Tag-based: outer object has "type": "login"
assert!(json.contains("\"type\":\"login\""));
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.item_type(), ItemType::Login);
}
#[test]
fn item_core_secure_note_round_trip_via_tag() {
use zeroize::Zeroizing;
let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) });
let json = serde_json::to_string(&core).unwrap();
assert!(json.contains("\"type\":\"secure_note\""));
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.item_type(), ItemType::SecureNote);
}
#[test]
fn item_core_round_trips_for_all_seven_types() {
use crate::ids::AttachmentId;
let cores = vec![
ItemCore::Login(LoginCore::default()),
ItemCore::SecureNote(SecureNoteCore::default()),
ItemCore::Identity(IdentityCore::default()),
ItemCore::Card(CardCore::default()),
ItemCore::Key(KeyCore::default()),
ItemCore::Document(DocumentCore {
filename: "x".into(),
mime_type: "text/plain".into(),
primary_attachment: AttachmentId("0123456789abcdef".into()),
}),
ItemCore::Totp(TotpCore::default()),
];
for core in cores {
let expected_type = core.item_type();
let json = serde_json::to_string(&core).unwrap();
let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed");
assert_eq!(parsed.item_type(), expected_type);
}
}
}

View File

@@ -0,0 +1,30 @@
//! Secure note: just a multiline body, Zeroizing.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecureNoteCore {
pub body: Zeroizing<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secure_note_round_trips() {
let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) };
let json = serde_json::to_string(&note).unwrap();
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.body.as_str(), "a multi\nline note");
}
#[test]
fn empty_body_round_trips() {
let note = SecureNoteCore::default();
let json = serde_json::to_string(&note).unwrap();
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
assert!(parsed.body.is_empty());
}
}

View File

@@ -0,0 +1,108 @@
//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpCore {
pub config: TotpConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotpConfig {
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
pub secret: Zeroizing<Vec<u8>>,
pub algorithm: TotpAlgorithm,
pub digits: u8,
pub period_seconds: u32,
pub kind: TotpKind,
}
impl Default for TotpConfig {
fn default() -> Self {
Self {
secret: Zeroizing::new(Vec::new()),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpAlgorithm {
#[default]
Sha1,
Sha256,
Sha512,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TotpKind {
Totp,
Hotp { counter: u64 },
Steam,
}
impl Default for TotpKind {
fn default() -> Self { TotpKind::Totp }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn totp_default_is_sha1_6_30_totp() {
let cfg = TotpConfig::default();
assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
assert_eq!(cfg.digits, 6);
assert_eq!(cfg.period_seconds, 30);
assert_eq!(cfg.kind, TotpKind::Totp);
}
#[test]
fn totp_round_trip() {
let core = TotpCore {
config: TotpConfig {
secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
algorithm: TotpAlgorithm::Sha256,
digits: 8,
period_seconds: 60,
kind: TotpKind::Totp,
},
issuer: Some("github".into()),
label: Some("alice@github".into()),
};
let json = serde_json::to_string(&core).unwrap();
let parsed: TotpCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.config.digits, 8);
assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
assert_eq!(parsed.issuer.as_deref(), Some("github"));
}
#[test]
fn hotp_carries_counter() {
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap();
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
match parsed.kind {
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
other => panic!("expected Hotp, got {:?}", other),
}
}
#[test]
fn steam_kind_serializes() {
let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("steam"));
}
}

View File

@@ -0,0 +1,79 @@
//! # relicario-core
//!
//! Platform-agnostic core library for the idfoto password manager.
//!
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
//! or typed structs, and all outputs are returned as byte vectors or typed structs.
//! This design makes the crate portable to WASM, Android (via JNI/UniFFI), and iOS
//! without any conditional compilation or platform shims.
//!
//! ## Modules
//!
//! - [`error`] — The unified error type ([`RelicarioError`]).
//! - [`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
//!
//! ```text
//! passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
//! -> Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
//! -> master_key (32 bytes)
//! -> XChaCha20-Poly1305(nonce=random 24 bytes)
//! -> encrypted entry/manifest
//! ```
pub mod error;
pub use error::{RelicarioError, Result};
pub mod crypto;
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
pub mod ids;
pub use ids::{AttachmentId, FieldId, ItemId};
pub mod time;
pub use time::{now_unix, MonthYear};
pub mod item_types;
pub use item_types::{ItemCore, ItemType};
pub mod item;
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
pub mod attachment;
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};
pub mod manifest;
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};
pub mod settings;
pub use settings::{
AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
SymbolCharset, TrashRetention, VaultSettings,
};
pub mod generators;
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
pub mod vault;
pub use vault::{
decrypt_item, decrypt_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_settings,
};
pub mod imgsecret;

View File

@@ -0,0 +1,159 @@
//! New typed-item manifest. Lives next to the old entry.rs Manifest
//! during this rewrite; entry.rs is deleted in Task 25.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::attachment::AttachmentSummary;
use crate::ids::ItemId;
use crate::item::Item;
use crate::item_types::ItemType;
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema_version: u32,
pub items: HashMap<ItemId, ManifestEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
pub id: ItemId,
pub r#type: ItemType,
pub title: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_hint: Option<String>,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
#[serde(default)]
pub attachment_summaries: Vec<AttachmentSummary>,
}
impl Manifest {
pub fn new() -> Self {
Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() }
}
pub fn upsert(&mut self, item: &Item) {
let entry = ManifestEntry::from_item(item);
self.items.insert(item.id.clone(), entry);
}
pub fn remove(&mut self, id: &ItemId) -> Option<ManifestEntry> {
self.items.remove(id)
}
pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> {
self.items.get(id)
}
/// Case-insensitive substring match on title and tags.
pub fn search(&self, query: &str) -> Vec<&ManifestEntry> {
let q = query.to_lowercase();
self.items
.values()
.filter(|e| {
e.title.to_lowercase().contains(&q)
|| e.tags.iter().any(|t| t.to_lowercase().contains(&q))
})
.collect()
}
}
impl Default for Manifest {
fn default() -> Self { Self::new() }
}
impl ManifestEntry {
pub fn from_item(item: &Item) -> Self {
Self {
id: item.id.clone(),
r#type: item.r#type,
title: item.title.clone(),
tags: item.tags.clone(),
favorite: item.favorite,
group: item.group.clone(),
icon_hint: derive_icon_hint(item),
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.iter().map(Into::into).collect(),
}
}
}
/// Derive an icon hint string from an item — for Login items, this is the URL hostname.
fn derive_icon_hint(item: &Item) -> Option<String> {
use crate::item_types::ItemCore;
match &item.core {
ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
#[test]
fn empty_manifest_has_schema_v2() {
let m = Manifest::new();
assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION);
assert!(m.items.is_empty());
}
#[test]
fn upsert_and_search() {
let mut m = Manifest::new();
let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default()));
item.tags = vec!["work".into()];
m.upsert(&item);
let results = m.search("github");
assert_eq!(results.len(), 1);
let by_tag = m.search("work");
assert_eq!(by_tag.len(), 1);
}
#[test]
fn icon_hint_is_login_url_host() {
use url::Url;
let mut m = Manifest::new();
let core = ItemCore::Login(LoginCore {
url: Some(Url::parse("https://api.github.com/login").unwrap()),
..Default::default()
});
let item = Item::new("X".into(), core);
m.upsert(&item);
let entry = m.items.values().next().unwrap();
assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com"));
}
#[test]
fn icon_hint_is_none_for_non_login() {
let mut m = Manifest::new();
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let entry = m.items.values().next().unwrap();
assert!(entry.icon_hint.is_none());
}
#[test]
fn manifest_round_trips() {
let mut m = Manifest::new();
let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let json = serde_json::to_string(&m).unwrap();
let parsed: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION);
assert_eq!(parsed.items.len(), 1);
}
}

View File

@@ -0,0 +1,184 @@
//! Vault-level settings: trash retention, history retention, generator
//! defaults, attachment caps, autofill TOFU acks.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultSettings {
pub trash_retention: TrashRetention,
pub field_history_retention: HistoryRetention,
pub generator_defaults: GeneratorRequest,
pub attachment_caps: AttachmentCaps,
/// hostname → unix-seconds first-acked
#[serde(default)]
pub autofill_origin_acks: HashMap<String, i64>,
}
impl Default for VaultSettings {
fn default() -> Self {
Self {
trash_retention: TrashRetention::Days(30),
field_history_retention: HistoryRetention::Forever,
generator_defaults: GeneratorRequest::default(),
attachment_caps: AttachmentCaps::default(),
autofill_origin_acks: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum TrashRetention {
Days(u32),
Forever,
}
impl TrashRetention {
pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool {
match self {
TrashRetention::Forever => false,
TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum HistoryRetention {
LastN(u32),
Days(u32),
Forever,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GeneratorRequest {
Bip39 {
word_count: u32,
separator: String,
capitalization: Capitalization,
},
Random {
length: u32,
classes: CharClasses,
symbol_charset: SymbolCharset,
},
}
impl Default for GeneratorRequest {
fn default() -> Self {
GeneratorRequest::Random {
length: 20,
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capitalization {
Lower,
Upper,
FirstOfEach,
Title,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CharClasses {
pub lower: bool,
pub upper: bool,
pub digits: bool,
pub symbols: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum SymbolCharset {
SafeOnly,
Extended,
Custom(String),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AttachmentCaps {
pub per_attachment_max_bytes: u64,
pub per_item_max_count: u32,
pub per_vault_soft_cap_bytes: u64,
pub per_vault_hard_cap_bytes: u64,
}
impl Default for AttachmentCaps {
fn default() -> Self {
Self {
per_attachment_max_bytes: 10 * 1024 * 1024,
per_item_max_count: 20,
per_vault_soft_cap_bytes: 100 * 1024 * 1024,
per_vault_hard_cap_bytes: 500 * 1024 * 1024,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_spec() {
let s = VaultSettings::default();
assert!(matches!(s.trash_retention, TrashRetention::Days(30)));
assert!(matches!(s.field_history_retention, HistoryRetention::Forever));
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
assert_eq!(s.attachment_caps.per_item_max_count, 20);
}
#[test]
fn trash_retention_purges_after_days() {
let r = TrashRetention::Days(30);
let now = 1_000_000_000;
let recently_trashed = now - 29 * 86_400;
let long_trashed = now - 31 * 86_400;
assert!(!r.should_purge(recently_trashed, now));
assert!(r.should_purge(long_trashed, now));
}
#[test]
fn trash_retention_forever_never_purges() {
let r = TrashRetention::Forever;
assert!(!r.should_purge(0, 1_000_000_000));
}
#[test]
fn settings_round_trip() {
let s = VaultSettings::default();
let json = serde_json::to_string(&s).unwrap();
let parsed: VaultSettings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.attachment_caps.per_attachment_max_bytes,
s.attachment_caps.per_attachment_max_bytes);
}
#[test]
fn random_generator_default_is_20_safe() {
match VaultSettings::default().generator_defaults {
GeneratorRequest::Random { length, classes, symbol_charset } => {
assert_eq!(length, 20);
assert!(classes.lower && classes.upper && classes.digits && classes.symbols);
assert!(matches!(symbol_charset, SymbolCharset::SafeOnly));
}
_ => panic!("expected Random default"),
}
}
#[test]
fn symbol_charset_custom_round_trips() {
let c = SymbolCharset::Custom("!@#".into());
let json = serde_json::to_string(&c).unwrap();
let parsed: SymbolCharset = serde_json::from_str(&json).unwrap();
match parsed {
SymbolCharset::Custom(s) => assert_eq!(s, "!@#"),
other => panic!("expected Custom, got {:?}", other),
}
}
}

View File

@@ -0,0 +1,63 @@
//! Time helpers and the `MonthYear` type used for card expiries.
use serde::{Deserialize, Serialize};
/// Current Unix timestamp in seconds.
pub fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
/// Month + year (1-12 / e.g. 2026). Used for card expiries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MonthYear {
pub month: u8,
pub year: u16,
}
impl MonthYear {
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
if !(1..=12).contains(&month) {
return Err("month must be 1..=12");
}
if year < 2000 || year > 2099 {
return Err("year must be 2000..=2099");
}
Ok(Self { month, year })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn now_unix_is_positive_and_recent() {
let t = now_unix();
assert!(t > 1_700_000_000); // after late 2023
assert!(t < 4_000_000_000); // before 2096
}
#[test]
fn month_year_constructor_rejects_bad_month() {
assert!(MonthYear::new(0, 2026).is_err());
assert!(MonthYear::new(13, 2026).is_err());
assert!(MonthYear::new(1, 2026).is_ok());
assert!(MonthYear::new(12, 2026).is_ok());
}
#[test]
fn month_year_constructor_rejects_bad_year() {
assert!(MonthYear::new(1, 1999).is_err());
assert!(MonthYear::new(1, 2100).is_err());
assert!(MonthYear::new(1, 2000).is_ok());
assert!(MonthYear::new(1, 2099).is_ok());
}
#[test]
fn month_year_round_trips_through_json() {
let my = MonthYear::new(7, 2030).unwrap();
let json = serde_json::to_string(&my).unwrap();
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, my);
}
}

View File

@@ -0,0 +1,90 @@
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
//!
//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
//! Plan 1B switches to the new helpers.
use zeroize::Zeroizing;
use crate::crypto::{decrypt, encrypt};
use crate::error::Result;
use crate::item::Item;
use crate::manifest::Manifest;
use crate::settings::VaultSettings;
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(item)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let item: Item = serde_json::from_slice(&plaintext)?;
Ok(item)
}
pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let manifest: Manifest = serde_json::from_slice(&plaintext)?;
Ok(manifest)
}
pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(settings)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<VaultSettings> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
Ok(settings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item_types::{ItemCore, SecureNoteCore};
fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) }
#[test]
fn item_round_trip() {
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new("hello".into()),
}));
let bytes = encrypt_item(&item, &key()).unwrap();
let decoded = decrypt_item(&bytes, &key()).unwrap();
assert_eq!(decoded.title, "note");
}
#[test]
fn manifest_round_trip() {
let mut m = Manifest::new();
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let bytes = encrypt_manifest(&m, &key()).unwrap();
let decoded = decrypt_manifest(&bytes, &key()).unwrap();
assert_eq!(decoded.items.len(), 1);
}
#[test]
fn settings_round_trip() {
let s = VaultSettings::default();
let bytes = encrypt_settings(&s, &key()).unwrap();
let decoded = decrypt_settings(&bytes, &key()).unwrap();
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
s.attachment_caps.per_attachment_max_bytes);
}
}

View File

@@ -0,0 +1,52 @@
//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement.
use relicario_core::{
AttachmentId, RelicarioError,
crypto::KdfParams,
decrypt_attachment, derive_master_key, encrypt_attachment,
};
use zeroize::Zeroizing;
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
fn make_key() -> Zeroizing<[u8; 32]> {
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap()
}
#[test]
fn attachment_round_trip_5kb() {
let plaintext: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
let key = make_key();
let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap();
assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext));
let dec = decrypt_attachment(&enc.bytes, &key).unwrap();
assert_eq!(&*dec, &plaintext);
}
#[test]
fn identical_plaintexts_yield_identical_aids() {
let plaintext = b"hello world";
let key = make_key();
let a = encrypt_attachment(plaintext, &key, 1024).unwrap();
let b = encrypt_attachment(plaintext, &key, 1024).unwrap();
assert_eq!(a.id, b.id);
// (Bytes will differ because nonce is random per-encryption — that's expected.)
}
#[test]
fn cap_enforcement_at_exact_max() {
let plaintext = vec![0u8; 1024];
let key = make_key();
// Exactly at max — should pass
let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap();
// One byte over — should fail
let err = encrypt_attachment(&plaintext, &key, 1023);
match err {
Err(RelicarioError::AttachmentTooLarge { size, max }) => {
assert_eq!(size, 1024);
assert_eq!(max, 1023);
}
other => panic!("expected AttachmentTooLarge, got {other:?}"),
}
}

View File

@@ -0,0 +1,63 @@
//! Field history end-to-end: capture on update, prune by retention policy,
//! survive encrypt/decrypt round-trip.
use relicario_core::{
Field, FieldValue, HistoryRetention, Item, ItemCore, Section,
crypto::KdfParams,
derive_master_key, decrypt_item, encrypt_item,
};
use relicario_core::item_types::LoginCore;
use zeroize::Zeroizing;
fn key() -> Zeroizing<[u8; 32]> {
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap()
}
#[test]
fn password_field_history_captured_on_update() {
let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap();
let hist = item.field_history.get(&fid).expect("history exists");
assert_eq!(hist.len(), 3);
assert_eq!(hist[0].value.as_str(), "v0");
assert_eq!(hist[2].value.as_str(), "v2");
}
#[test]
fn prune_last_n_keeps_most_recent() {
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
for i in 1..=10 {
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
}
item.prune_history(&HistoryRetention::LastN(3), 0);
let hist = &item.field_history[&fid];
assert_eq!(hist.len(), 3);
// Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured)
assert!(hist.last().unwrap().value.as_str().starts_with('v'));
}
#[test]
fn history_survives_encrypt_decrypt() {
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
let blob = encrypt_item(&item, &key()).unwrap();
let decoded = decrypt_item(&blob, &key()).unwrap();
let hist = decoded.field_history.get(&fid).expect("history survived");
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].value.as_str(), "v0");
}

View File

@@ -0,0 +1,54 @@
//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with
//! UnsupportedFormatVersion, length-prefix construction guarantees domain
//! separation.
use relicario_core::{
RelicarioError,
crypto::{KdfParams, VERSION_BYTE},
decrypt, derive_master_key, encrypt,
};
use zeroize::Zeroizing;
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
#[test]
fn version_byte_is_2() {
assert_eq!(VERSION_BYTE, 0x02);
}
#[test]
fn fresh_ciphertext_starts_with_0x02() {
let key = Zeroizing::new([0u8; 32]);
// encrypt(key: &[u8; 32], plaintext: &[u8])
let ct = encrypt(&key, b"hello").unwrap();
assert_eq!(ct[0], 0x02);
}
#[test]
fn v1_blob_is_rejected_with_unsupported_format_version() {
// v1 layout: [0x01][24 nonce bytes][16 tag bytes]
let mut blob = vec![0x01u8];
blob.extend_from_slice(&[0u8; 24 + 16]);
let key = Zeroizing::new([0u8; 32]);
// decrypt(key: &[u8; 32], data: &[u8])
let err = decrypt(&key, &blob);
match err {
Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => {
assert_eq!(found, 0x01);
assert_eq!(expected, 0x02);
}
other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
}
}
#[test]
fn length_prefix_distinguishes_concat_collisions() {
let salt = [0u8; 32];
let img = [0x44u8; 32];
let p1 = b"abc";
let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...)
// could be made to collide. With length-prefix they cannot.
let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap();
let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap();
assert_ne!(*k1, *k2);
}

View File

@@ -0,0 +1,89 @@
//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity,
//! zxcvbn strength gate.
//!
//! # Note on length cap
//!
//! `generate_password` enforces `length <= 128`. The task originally specified
//! `length: 10_000` in a single call, but that would error at runtime.
//!
//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with
//! `length: 128` to gather 10,240 characters total, then aggregate per-class
//! counts before asserting proportions. The ±5pp tolerance is unchanged because
//! sample size is the same (~10k chars).
use relicario_core::{
Capitalization, CharClasses, GeneratorRequest, SymbolCharset,
generate_passphrase, generate_password, validate_passphrase_strength,
};
#[test]
fn random_password_class_balance_is_reasonable() {
// Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics.
// (generate_password caps at length 128, so we cannot do a single 10,000-char call.)
let req = GeneratorRequest::Random {
length: 128,
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
};
let mut lower = 0usize;
let mut upper = 0usize;
let mut digits = 0usize;
let mut total = 0usize;
for _ in 0..80 {
let pw = generate_password(&req).unwrap();
lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count();
upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count();
digits += pw.chars().filter(|c| c.is_ascii_digit()).count();
total += pw.len();
}
let symbols = total - lower - upper - digits;
// Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74
// Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2%
// Allow ±5pp slop.
let t = total as f64;
let assert_pct = |label: &str, actual: usize, expected_pct: f64| {
let pct = (actual as f64) / t * 100.0;
assert!(
(pct - expected_pct).abs() < 5.0,
"{label}: actual {pct:.1}% vs expected {expected_pct:.1}%"
);
};
assert_pct("lower", lower, 26.0 / 74.0 * 100.0);
assert_pct("upper", upper, 26.0 / 74.0 * 100.0);
assert_pct("digits", digits, 10.0 / 74.0 * 100.0);
assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0);
}
#[test]
fn bip39_5_word_passphrase_passes_zxcvbn_gate() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3");
}
#[test]
fn common_weak_passphrases_fail_gate() {
for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] {
assert!(
validate_passphrase_strength(weak).is_err(),
"expected '{weak}' to fail gate"
);
}
}
#[test]
fn random_passwords_are_unique_across_calls() {
let req = GeneratorRequest::default();
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let pw = generate_password(&req).unwrap();
assert!(seen.insert(pw.as_str().to_owned()));
}
}

View File

@@ -0,0 +1,111 @@
//! End-to-end integration tests for the typed-item core.
use relicario_core::{
crypto::KdfParams,
derive_master_key, encrypt_item, decrypt_item,
encrypt_manifest, decrypt_manifest,
encrypt_settings, decrypt_settings,
Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings,
};
use relicario_core::item_types::{LoginCore, SecureNoteCore};
use url::Url;
use zeroize::Zeroizing;
fn fast_params() -> KdfParams {
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
#[test]
fn full_workflow_login_and_note() {
let salt = [0xAAu8; 32];
let img = [0xBBu8; 32];
let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap();
let mut manifest = Manifest::new();
let settings = VaultSettings::default();
// Add a Login
let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com").unwrap()),
totp: None,
}));
manifest.upsert(&login);
let login_blob = encrypt_item(&login, &key).unwrap();
// Add a SecureNote
let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new("recovery codes go here".into()),
}));
manifest.upsert(&note);
let note_blob = encrypt_item(&note, &key).unwrap();
// Encrypt manifest + settings
let manifest_blob = encrypt_manifest(&manifest, &key).unwrap();
let settings_blob = encrypt_settings(&settings, &key).unwrap();
// Decrypt + verify
let m = decrypt_manifest(&manifest_blob, &key).unwrap();
assert_eq!(m.items.len(), 2);
let l: Item = decrypt_item(&login_blob, &key).unwrap();
let n: Item = decrypt_item(&note_blob, &key).unwrap();
let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap();
assert_eq!(l.title, "GitHub");
assert_eq!(n.title, "recovery");
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
}
#[test]
fn two_factor_independence() {
// Same passphrase, different image_secret → different keys.
let salt = [0u8; 32];
let img_a = [0x01u8; 32];
let img_b = [0x02u8; 32];
let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap();
let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap();
assert_ne!(*key_a, *key_b);
// Different passphrase, same image_secret → different keys.
let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap();
assert_ne!(*key_a, *key_c);
}
#[test]
fn field_history_persists_through_round_trip() {
let salt = [0u8; 32];
let img = [0u8; 32];
let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap();
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
let blob = encrypt_item(&item, &key).unwrap();
let decoded = decrypt_item(&blob, &key).unwrap();
let hist = decoded.field_history.get(&fid).unwrap();
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].value.as_str(), "v0");
assert_eq!(hist[1].value.as_str(), "v1");
}
#[test]
fn wrong_key_fails_with_opaque_decrypt() {
use relicario_core::RelicarioError;
let salt = [0u8; 32];
let img = [0u8; 32];
let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap();
let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap();
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
let blob = encrypt_item(&item, &right).unwrap();
let err = decrypt_item(&blob, &wrong);
assert!(matches!(err, Err(RelicarioError::Decrypt)));
}