chore: rename project from idfoto to relicario

Sweeping rename across crates, CLI binary, WASM bindings, extension, docs,
and vault metadata paths. Git remote updated to relicario.git.

- crates/idfoto-{core,cli,wasm} -> crates/relicario-{core,cli,wasm}
- IdfotoError -> RelicarioError
- IDFOTO_IMAGE env var -> RELICARIO_IMAGE
- ~/.config/idfoto -> ~/.config/relicario
- .idfoto/ vault metadata dir -> .relicario/ (breaking; pre-release)
- Binary name idfoto -> relicario
- Extension wasm module idfoto_wasm -> relicario_wasm
- Storage key idfotoSettings -> relicarioSettings
- All doc filenames and content references updated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 16:47:02 -04:00
parent 20ff1d9f47
commit 519a6f0e36
51 changed files with 949 additions and 949 deletions

View File

@@ -0,0 +1,18 @@
[package]
name = "relicario-core"
version = "0.1.0"
edition = "2021"
description = "Core library for relicario 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"] }
[dev-dependencies]

View File

@@ -0,0 +1,341 @@
//! 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** (`0x01`): 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 crate::error::{RelicarioError, Result};
/// Current binary format version. Increment this if the ciphertext layout changes.
const VERSION_BYTE: u8 = 0x01;
/// 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 version = data[0];
if version != VERSION_BYTE {
return Err(RelicarioError::Format(format!(
"unknown version byte: 0x{:02x}",
version
)));
}
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<[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);
// Concatenate passphrase + image_secret as the password input.
// This ensures both factors contribute to the derived key: knowing only
// the passphrase (without the reference image) or only the image secret
// (without the passphrase) is insufficient to derive the correct master key.
let mut password = Vec::with_capacity(passphrase.len() + 32);
password.extend_from_slice(passphrase);
password.extend_from_slice(image_secret);
let mut output = [0u8; 32];
argon2
.hash_password_into(&password, salt, &mut output)
.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, relicario!";
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 0x01
assert_eq!(ciphertext[0], 0x01);
}
}

View File

@@ -0,0 +1,335 @@
//! 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()));
}
}

View File

@@ -0,0 +1,86 @@
//! 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 -> entry lookup -> image
/// steganography -> serialization -> device keys.
#[derive(Debug, Error)]
pub enum RelicarioError {
/// The Argon2id key derivation failed. This typically means invalid KDF
/// parameters were supplied (e.g., memory cost below Argon2's minimum).
#[error("key derivation failed: {0}")]
Kdf(String),
/// XChaCha20-Poly1305 encryption failed. In practice this is extremely rare
/// -- the only realistic cause is an internal library error, since the cipher
/// accepts arbitrary-length plaintext.
#[error("encryption failed: {0}")]
Encrypt(String),
/// Authenticated decryption failed. This means either the wrong master key
/// was used (wrong passphrase or wrong reference image) or the ciphertext
/// was tampered with / corrupted in transit or at rest. The error message is
/// intentionally vague to avoid leaking information about which factor was
/// wrong (passphrase vs. image).
#[error("decryption failed: wrong key or corrupted data")]
Decrypt,
/// The binary ciphertext blob does not match the expected format (e.g.,
/// too short to contain the version byte + nonce + tag, or an unrecognized
/// version byte). This usually indicates file corruption or a version
/// mismatch between the writer and reader.
#[error("invalid vault format: {0}")]
Format(String),
/// A vault entry was looked up by ID but does not exist in the manifest.
/// The string payload is the missing entry ID.
#[error("entry not found: {0}")]
EntryNotFound(String),
/// A general error from the image steganography subsystem (imgsecret).
/// Covers issues like failing to decode the carrier JPEG or failing to
/// encode the output JPEG after modification.
#[error("imgsecret: {0}")]
ImgSecret(String),
/// The carrier image is too small to hold the embedded secret with
/// sufficient redundancy. The embed region (central 70% of the image)
/// must contain at least `BLOCKS_PER_COPY * MIN_COPIES` 8x8 blocks.
#[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,
},
/// Secret extraction from a JPEG failed. This can mean:
/// - The image never had a secret embedded in it.
/// - The image was recompressed below Q85, destroying the QIM watermarks.
/// - The image was cropped beyond the 15% crumple zone.
/// - Majority-vote confidence fell below the 60% threshold on one or more bits.
#[error("extraction failed: no valid secret found in image")]
ExtractionFailed,
/// JSON serialization or deserialization of an entry or manifest failed.
/// Wraps [`serde_json::Error`] transparently via `#[from]`.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// An error related to device ed25519 key operations. Device keys are
/// separate from the vault KDF -- revoking a device does not require
/// rotating the passphrase or reference image.
#[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>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
//! # relicario-core
//!
//! Platform-agnostic core library for the relicario 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`]) used across the crate.
//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated
//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer.
//! - [`entry`] -- The vault data model: [`Entry`] (full credential),
//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry
//! index that lets you list/search without decrypting every entry).
//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON
//! before encrypting, and deserialize after decrypting.
//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a
//! 256-bit secret in a JPEG image. This is the novel component that provides the
//! second authentication factor.
//!
//! ## 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};
pub mod entry;
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub mod vault;
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
pub mod imgsecret;

View File

@@ -0,0 +1,150 @@
//! Typed encryption/decryption wrappers for vault entries and manifests.
//!
//! This module bridges the gap between the raw bytes-in/bytes-out layer in
//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function
//! follows the same pattern:
//!
//! - **Encrypt**: serialize the struct to JSON via serde, then encrypt the JSON
//! bytes with [`crate::crypto::encrypt`].
//! - **Decrypt**: decrypt the ciphertext with [`crate::crypto::decrypt`], then
//! deserialize the resulting JSON bytes back into the typed struct.
//!
//! ## Why a single master key
//!
//! All entries and the manifest are encrypted under the same `master_key`. This is
//! simpler than a per-entry subkey hierarchy and sufficient for family-scale vaults
//! (typically < 1000 entries). The security properties are equivalent: an attacker
//! who compromises the master key can decrypt everything regardless of whether
//! subkeys exist, and the vault's threat model already assumes the master key is
//! the single point of trust (protected by the two-factor KDF).
use crate::crypto;
use crate::entry::{Entry, Manifest};
use crate::error::Result;
/// Serialize an [`Entry`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `entries/<id>.enc` by the CLI.
///
/// # Errors
///
/// - [`crate::RelicarioError::Json`] if JSON serialization fails (should not happen
/// with well-formed Entry structs).
/// - [`crate::RelicarioError::Encrypt`] if the underlying AEAD operation fails.
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
let json = serde_json::to_vec(entry)?;
crypto::encrypt(master_key, &json)
}
/// Decrypt an entry blob and deserialize it back into an [`Entry`].
///
/// # Errors
///
/// - [`crate::RelicarioError::Decrypt`] if the master key is wrong or the data is
/// tampered.
/// - [`crate::RelicarioError::Format`] if the ciphertext blob has an invalid header.
/// - [`crate::RelicarioError::Json`] if the decrypted JSON is malformed.
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
let json = crypto::decrypt(master_key, data)?;
let entry: Entry = serde_json::from_slice(&json)?;
Ok(entry)
}
/// Serialize a [`Manifest`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `manifest.enc` by the CLI.
///
/// # Errors
///
/// Same as [`encrypt_entry`].
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
crypto::encrypt(master_key, &json)
}
/// Decrypt a manifest blob and deserialize it back into a [`Manifest`].
///
/// # Errors
///
/// Same as [`decrypt_entry`].
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
let json = crypto::decrypt(master_key, data)?;
let manifest: Manifest = serde_json::from_slice(&json)?;
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::ManifestEntry;
fn test_key_a() -> [u8; 32] {
[0x42u8; 32]
}
fn test_key_b() -> [u8; 32] {
[0x99u8; 32]
}
fn sample_entry() -> Entry {
Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "secret123".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(),
}
}
#[test]
fn entry_encrypt_decrypt_round_trip() {
let key = test_key_a();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key, &entry).unwrap();
let decoded = decrypt_entry(&key, &ciphertext).unwrap();
assert_eq!(decoded.name, "GitHub");
assert_eq!(decoded.password, "secret123");
assert_eq!(decoded.username, Some("alice".to_string()));
}
#[test]
fn manifest_encrypt_decrypt_round_trip() {
let key = test_key_a();
let mut manifest = Manifest::new();
manifest.add_entry(
"deadbeef".to_string(),
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(),
},
);
let ciphertext = encrypt_manifest(&key, &manifest).unwrap();
let decoded = decrypt_manifest(&key, &ciphertext).unwrap();
assert_eq!(decoded.version, 1);
assert!(decoded.entries.contains_key("deadbeef"));
assert_eq!(decoded.entries["deadbeef"].name, "GitHub");
}
#[test]
fn entry_wrong_key_fails() {
let key_a = test_key_a();
let key_b = test_key_b();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key_a, &entry).unwrap();
let result = decrypt_entry(&key_b, &ciphertext);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,153 @@
use relicario_core::{
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
};
use rand::RngCore;
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(width, height, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.unwrap();
buf
}
fn fast_params() -> KdfParams {
KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
}
}
#[test]
fn full_vault_workflow() {
// 1. Generate carrier JPEG
let carrier = make_test_jpeg(400, 300);
// 2. Generate random image_secret and embed
let mut image_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret);
let stego = relicario_core::imgsecret::embed(&carrier, &image_secret).unwrap();
// 3. Extract and verify
let extracted = relicario_core::imgsecret::extract(&stego).unwrap();
assert_eq!(extracted, image_secret, "extracted image_secret must match embedded");
// 4. Derive master_key with fast params
let passphrase = b"test-passphrase-long-enough";
let mut salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
let params = fast_params();
let master_key = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
// 5. Create and encrypt an Entry
let entry = Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "supersecret123!".to_string(),
notes: Some("my main account".to_string()),
totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let encrypted = encrypt_entry(&master_key, &entry).unwrap();
// 6. Decrypt and verify fields match
let decrypted = decrypt_entry(&master_key, &encrypted).unwrap();
assert_eq!(decrypted.name, "GitHub");
assert_eq!(decrypted.password, "supersecret123!");
assert_eq!(decrypted.username, Some("alice".to_string()));
assert_eq!(decrypted.url, Some("https://github.com".to_string()));
assert_eq!(decrypted.notes, Some("my main account".to_string()));
// 7. Wrong passphrase -> different key -> decrypt fails
let wrong_key = derive_master_key(b"wrong-passphrase-entirely", &image_secret, &salt, &params).unwrap();
assert!(
decrypt_entry(&wrong_key, &encrypted).is_err(),
"decryption with wrong passphrase must fail"
);
// 8. Wrong image_secret -> different key -> decrypt fails
let mut wrong_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut wrong_secret);
// Make sure it's actually different
if wrong_secret == image_secret {
wrong_secret[0] ^= 0xFF;
}
let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, &params).unwrap();
assert!(
decrypt_entry(&wrong_key2, &encrypted).is_err(),
"decryption with wrong image_secret must fail"
);
// 9. Manifest round-trip
let entry_id = generate_entry_id();
let mut manifest = Manifest::new();
manifest.add_entry(
entry_id.clone(),
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(),
},
);
let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap();
let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap();
assert_eq!(manifest_dec.version, 1);
assert!(manifest_dec.entries.contains_key(&entry_id));
assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub");
}
#[test]
fn two_factor_independence() {
let mut salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
let params = fast_params();
let passphrase_a = b"passphrase-alpha";
let passphrase_b = b"passphrase-bravo";
let mut image_secret_a = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret_a);
let mut image_secret_b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret_b);
// Ensure they differ
if image_secret_a == image_secret_b {
image_secret_b[0] ^= 0xFF;
}
// 1. (passphrase_A, image_A)
let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, &params).unwrap();
// 2. (passphrase_B, image_A) -> different from #1
let key_ba = derive_master_key(passphrase_b, &image_secret_a, &salt, &params).unwrap();
assert_ne!(key_aa, key_ba, "different passphrase must produce different key");
// 3. (passphrase_A, image_B) -> different from #1
let key_ab = derive_master_key(passphrase_a, &image_secret_b, &salt, &params).unwrap();
assert_ne!(key_aa, key_ab, "different image_secret must produce different key");
// 4. (passphrase_B, image_B) -> different from all above
let key_bb = derive_master_key(passphrase_b, &image_secret_b, &salt, &params).unwrap();
assert_ne!(key_bb, key_aa, "key_bb must differ from key_aa");
assert_ne!(key_bb, key_ba, "key_bb must differ from key_ba");
assert_ne!(key_bb, key_ab, "key_bb must differ from key_ab");
}