Brand name uses capital R in user-facing text — extension UI strings, CLI clap help / descriptions / error prose, markdown docs. Lowercase preserved for the binary command, crate names, npm package, file paths, env vars, and code identifiers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
88 KiB
Relicario Core + CLI Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a working git-backed password manager with a Rust core library and CLI that can create vaults, add/get/list/edit/rm credentials, sync via git, and manage device keys — all backed by the reference-image + passphrase two-factor KDF.
Architecture: Cargo workspace with two crates: relicario-core (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and relicario-cli (filesystem, git, terminal I/O). The core takes bytes and returns bytes; the CLI handles all platform interaction. TDD throughout.
Tech Stack: Rust (stable, 2021 edition), argon2, chacha20poly1305, image, serde/serde_json, clap, ed25519-dalek
Scope: This is Plan 1 of 2. This plan covers relicario-core and relicario-cli. Plan 2 (relicario-wasm + Chrome extension) follows after this is working. This plan produces a complete, usable CLI password manager.
Prerequisites: Rust stable installed via rustup. Git installed. A test JPEG image (any cell phone photo) available for manual testing.
Design spec: docs/superpowers/specs/2026-04-11-relicario-design.md
File Structure
relicario/ (project root = /home/alee/Sources/relicario)
├── Cargo.toml # workspace root
├── crates/
│ ├── relicario-core/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs # re-exports public API
│ │ ├── error.rs # RelicarioError enum (thiserror)
│ │ ├── crypto.rs # derive_master_key(), encrypt(), decrypt()
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs
│ │ ├── vault.rs # encrypt/decrypt entries + manifest, binary format
│ │ └── imgsecret.rs # embed(), extract() — DCT embedding primitive
│ └── relicario-cli/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # clap CLI with all subcommands
├── docs/
│ └── superpowers/
│ ├── specs/
│ │ └── 2026-04-11-relicario-design.md
│ └── plans/
│ └── 2026-04-11-relicario-core-cli.md (this file)
└── README.md
Task 1: Workspace Scaffolding
Files:
-
Create:
Cargo.toml -
Create:
crates/relicario-core/Cargo.toml -
Create:
crates/relicario-core/src/lib.rs -
Create:
crates/relicario-cli/Cargo.toml -
Create:
crates/relicario-cli/src/main.rs -
Step 1: Create workspace root Cargo.toml
# Cargo.toml
[workspace]
resolver = "2"
members = [
"crates/relicario-core",
"crates/relicario-cli",
]
- Step 2: Create relicario-core crate
# crates/relicario-core/Cargo.toml
[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]
// crates/relicario-core/src/lib.rs
pub mod error;
- Step 3: Create relicario-cli crate
# crates/relicario-cli/Cargo.toml
[package]
name = "relicario-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for relicario password manager"
[[bin]]
name = "relicario"
path = "src/main.rs"
[dependencies]
relicario-core = { path = "../relicario-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "5"
arboard = "3"
dirs = "5"
// crates/relicario-cli/src/main.rs
fn main() {
println!("relicario v0.1.0");
}
- Step 4: Verify build
Run: cargo build
Expected: Compiles with no errors. May show warnings about unused dependencies — that's fine.
- Step 5: Commit
git init
echo "target/" > .gitignore
echo ".superpowers/" >> .gitignore
git add Cargo.toml crates/ .gitignore docs/
git commit -m "feat: scaffold Cargo workspace with relicario-core and relicario-cli"
Task 2: Error Types
Files:
-
Create:
crates/relicario-core/src/error.rs -
Modify:
crates/relicario-core/src/lib.rs -
Step 1: Write the error enum
// crates/relicario-core/src/error.rs
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RelicarioError {
#[error("key derivation failed: {0}")]
Kdf(String),
#[error("encryption failed: {0}")]
Encrypt(String),
#[error("decryption failed: wrong key or corrupted data")]
Decrypt,
#[error("invalid vault format: {0}")]
Format(String),
#[error("entry not found: {0}")]
EntryNotFound(String),
#[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),
}
pub type Result<T> = std::result::Result<T, RelicarioError>;
- Step 2: Update lib.rs to re-export
// crates/relicario-core/src/lib.rs
pub mod error;
pub use error::{RelicarioError, Result};
- Step 3: Verify build
Run: cargo build
Expected: Compiles cleanly.
- Step 4: Commit
git add crates/relicario-core/src/error.rs crates/relicario-core/src/lib.rs
git commit -m "feat: add RelicarioError enum with thiserror"
Task 3: Crypto — Key Derivation
Files:
-
Create:
crates/relicario-core/src/crypto.rs -
Modify:
crates/relicario-core/src/lib.rs -
Step 1: Write the failing test
// crates/relicario-core/src/crypto.rs
// ... (implementation comes in step 3)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_master_key_deterministic() {
let passphrase = b"apple forest thunder mountain";
let image_secret = [0xABu8; 32];
let salt = [0x01u8; 32];
let params = KdfParams::default();
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
assert_eq!(key1, key2, "same inputs must produce same key");
}
#[test]
fn derive_master_key_different_passphrase() {
let image_secret = [0xABu8; 32];
let salt = [0x01u8; 32];
let params = KdfParams::default();
let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap();
let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn derive_master_key_different_image_secret() {
let passphrase = b"same passphrase";
let salt = [0x01u8; 32];
let params = KdfParams::default();
let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap();
let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap();
assert_ne!(key1, key2);
}
}
- Step 2: Run test to verify it fails
Run: cargo test -p relicario-core derive_master_key
Expected: FAIL — derive_master_key and KdfParams not defined.
- Step 3: Write the implementation
// crates/relicario-core/src/crypto.rs
use argon2::{Algorithm, Argon2, Params, Version};
use crate::error::{RelicarioError, Result};
/// Argon2id tuning parameters. Stored in .relicario/params.json.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct KdfParams {
/// Memory cost in KiB (default: 65536 = 64 MiB)
pub argon2_m: u32,
/// Time cost / iterations (default: 3)
pub argon2_t: u32,
/// Parallelism (default: 4)
pub argon2_p: u32,
}
impl Default for KdfParams {
fn default() -> Self {
Self {
argon2_m: 65536,
argon2_t: 3,
argon2_p: 4,
}
}
}
/// Derive a 32-byte master key from passphrase + image_secret + salt.
///
/// password = passphrase_bytes || image_secret_bytes (concatenated)
/// salt = vault_salt (32 bytes from .relicario/salt)
pub fn derive_master_key(
passphrase: &[u8],
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<[u8; 32]> {
// Concatenate passphrase and image_secret as the Argon2id "password" input
let mut password = Vec::with_capacity(passphrase.len() + 32);
password.extend_from_slice(passphrase);
password.extend_from_slice(image_secret);
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);
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::*;
// Use fast params for tests so they don't take forever
fn test_params() -> KdfParams {
KdfParams {
argon2_m: 256, // 256 KiB — fast for tests
argon2_t: 1,
argon2_p: 1,
}
}
#[test]
fn derive_master_key_deterministic() {
let passphrase = b"apple forest thunder mountain";
let image_secret = [0xABu8; 32];
let salt = [0x01u8; 32];
let params = test_params();
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
assert_eq!(key1, key2, "same inputs must produce same key");
}
#[test]
fn derive_master_key_different_passphrase() {
let image_secret = [0xABu8; 32];
let salt = [0x01u8; 32];
let params = test_params();
let key1 = derive_master_key(b"passphrase one", &image_secret, &salt, ¶ms).unwrap();
let key2 = derive_master_key(b"passphrase two", &image_secret, &salt, ¶ms).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn derive_master_key_different_image_secret() {
let passphrase = b"same passphrase";
let salt = [0x01u8; 32];
let params = test_params();
let key1 = derive_master_key(passphrase, &[0xAAu8; 32], &salt, ¶ms).unwrap();
let key2 = derive_master_key(passphrase, &[0xBBu8; 32], &salt, ¶ms).unwrap();
assert_ne!(key1, key2);
}
}
- Step 4: Run tests
Run: cargo test -p relicario-core derive_master_key
Expected: All 3 tests PASS.
- Step 5: Update lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod error;
pub use crypto::{derive_master_key, KdfParams};
pub use error::{RelicarioError, Result};
- Step 6: Commit
git add crates/relicario-core/src/
git commit -m "feat: add Argon2id key derivation with tests"
Task 4: Crypto — Encrypt / Decrypt
Files:
-
Modify:
crates/relicario-core/src/crypto.rs -
Step 1: Write the failing tests
Add to crates/relicario-core/src/crypto.rs inside the mod tests block:
#[test]
fn encrypt_decrypt_round_trip() {
let key = [0x42u8; 32];
let plaintext = b"hello world, this is a secret";
let ciphertext = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn decrypt_wrong_key_fails() {
let key = [0x42u8; 32];
let wrong_key = [0x43u8; 32];
let plaintext = b"secret data";
let ciphertext = encrypt(&key, plaintext).unwrap();
let result = decrypt(&wrong_key, &ciphertext);
assert!(result.is_err());
}
#[test]
fn decrypt_tampered_data_fails() {
let key = [0x42u8; 32];
let plaintext = b"secret data";
let mut ciphertext = encrypt(&key, plaintext).unwrap();
// Flip a byte in the ciphertext portion (after version + nonce = 25 bytes)
if ciphertext.len() > 26 {
ciphertext[26] ^= 0xFF;
}
let result = decrypt(&key, &ciphertext);
assert!(result.is_err());
}
#[test]
fn ciphertext_format_has_correct_structure() {
let key = [0x42u8; 32];
let plaintext = b"test";
let ciphertext = encrypt(&key, plaintext).unwrap();
// version(1) + nonce(24) + ciphertext(4) + tag(16) = 45
assert_eq!(ciphertext.len(), 1 + 24 + plaintext.len() + 16);
assert_eq!(ciphertext[0], 0x01); // version byte
}
- Step 2: Run tests to verify they fail
Run: cargo test -p relicario-core encrypt
Expected: FAIL — encrypt and decrypt not defined.
- Step 3: Write the implementation
Add to crates/relicario-core/src/crypto.rs, above the #[cfg(test)] block:
use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng},
XChaCha20Poly1305, XNonce,
};
use rand::RngCore;
/// Current format version byte.
const FORMAT_VERSION: u8 = 0x01;
/// XChaCha20-Poly1305 nonce size in bytes.
const NONCE_SIZE: usize = 24;
/// Encrypt plaintext with XChaCha20-Poly1305.
///
/// Output format: version(1) || nonce(24) || ciphertext(N) || tag(16)
/// Nonce is generated fresh from CSPRNG on each call.
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
let mut nonce_bytes = [0u8; NONCE_SIZE];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|_| RelicarioError::Encrypt("XChaCha20-Poly1305 encryption failed".into()))?;
let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
output.push(FORMAT_VERSION);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
/// Decrypt ciphertext produced by encrypt().
///
/// Expects format: version(1) || nonce(24) || ciphertext(N) || tag(16)
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
let min_len = 1 + NONCE_SIZE + 16; // version + nonce + tag (empty plaintext)
if data.len() < min_len {
return Err(RelicarioError::Format(format!(
"ciphertext too short: {} bytes, need at least {}",
data.len(),
min_len
)));
}
let version = data[0];
if version != FORMAT_VERSION {
return Err(RelicarioError::Format(format!(
"unsupported format version: {version}"
)));
}
let nonce = XNonce::from_slice(&data[1..1 + NONCE_SIZE]);
let ciphertext = &data[1 + NONCE_SIZE..];
let cipher = XChaCha20Poly1305::new(key.into());
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)
}
- Step 4: Fix the import for OsRng
The OsRng import from chacha20poly1305 may not be re-exported. Update the imports at the top of crypto.rs:
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use rand::rngs::OsRng;
use rand::RngCore;
- Step 5: Run all crypto tests
Run: cargo test -p relicario-core
Expected: All tests PASS (3 KDF tests + 4 encrypt/decrypt tests).
- Step 6: Update lib.rs exports
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod error;
pub use crypto::{derive_master_key, encrypt, decrypt, KdfParams};
pub use error::{RelicarioError, Result};
- Step 7: Commit
git add crates/relicario-core/src/
git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format"
Task 5: Entry & Manifest Data Model
Files:
-
Create:
crates/relicario-core/src/entry.rs -
Modify:
crates/relicario-core/src/lib.rs -
Step 1: Write tests for serialization
// crates/relicario-core/src/entry.rs
// ... (implementation in step 3)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_serialization_round_trip() {
let entry = Entry {
name: "GitHub".into(),
url: Some("https://github.com/login".into()),
username: Some("alee".into()),
password: "hunter2".into(),
notes: Some("2FA enabled".into()),
totp_secret: None,
created_at: "2026-04-11T22:30:00Z".into(),
updated_at: "2026-04-11T22:30:00Z".into(),
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "GitHub");
assert_eq!(deserialized.password, "hunter2");
}
#[test]
fn manifest_add_and_lookup() {
let mut manifest = Manifest::new();
manifest.add_entry(
"a1b2c3d4".into(),
ManifestEntry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
updated_at: "2026-04-11T22:30:00Z".into(),
},
);
assert_eq!(manifest.entries.len(), 1);
assert!(manifest.entries.contains_key("a1b2c3d4"));
}
#[test]
fn manifest_serialization_round_trip() {
let mut manifest = Manifest::new();
manifest.add_entry(
"a1b2c3d4".into(),
ManifestEntry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
updated_at: "2026-04-11T22:30:00Z".into(),
},
);
let json = serde_json::to_string(&manifest).unwrap();
let deserialized: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.entries.len(), 1);
assert_eq!(deserialized.version, 1);
}
#[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()));
}
}
- Step 2: Run tests to verify they fail
Run: cargo test -p relicario-core entry
Expected: FAIL — types not defined.
- Step 3: Write the implementation
// crates/relicario-core/src/entry.rs
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A single password entry (stored encrypted in entries/<id>.enc).
#[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>,
pub created_at: String,
pub updated_at: String,
}
/// Summary info about an entry (stored in the manifest).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
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 updated_at: String,
}
/// The vault manifest — maps entry IDs to their metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub entries: HashMap<String, ManifestEntry>,
pub version: u32,
}
impl Manifest {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
version: 1,
}
}
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
self.entries.insert(id, entry);
}
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
self.entries.remove(id)
}
/// Case-insensitive substring search on name and URL.
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
let query_lower = query.to_lowercase();
self.entries
.iter()
.filter(|(_, e)| {
e.name.to_lowercase().contains(&query_lower)
|| e.url
.as_ref()
.map(|u| u.to_lowercase().contains(&query_lower))
.unwrap_or(false)
})
.collect()
}
}
/// Generate a random 8-character hex entry ID.
pub fn generate_entry_id() -> String {
let mut rng = rand::thread_rng();
let value: u32 = rng.gen();
format!("{:08x}", value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_serialization_round_trip() {
let entry = Entry {
name: "GitHub".into(),
url: Some("https://github.com/login".into()),
username: Some("alee".into()),
password: "hunter2".into(),
notes: Some("2FA enabled".into()),
totp_secret: None,
created_at: "2026-04-11T22:30:00Z".into(),
updated_at: "2026-04-11T22:30:00Z".into(),
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "GitHub");
assert_eq!(deserialized.password, "hunter2");
}
#[test]
fn manifest_add_and_lookup() {
let mut manifest = Manifest::new();
manifest.add_entry(
"a1b2c3d4".into(),
ManifestEntry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
updated_at: "2026-04-11T22:30:00Z".into(),
},
);
assert_eq!(manifest.entries.len(), 1);
assert!(manifest.entries.contains_key("a1b2c3d4"));
}
#[test]
fn manifest_serialization_round_trip() {
let mut manifest = Manifest::new();
manifest.add_entry(
"a1b2c3d4".into(),
ManifestEntry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
updated_at: "2026-04-11T22:30:00Z".into(),
},
);
let json = serde_json::to_string(&manifest).unwrap();
let deserialized: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.entries.len(), 1);
assert_eq!(deserialized.version, 1);
}
#[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(
"aaa".into(),
ManifestEntry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: None,
updated_at: "2026-04-11T00:00:00Z".into(),
},
);
manifest.add_entry(
"bbb".into(),
ManifestEntry {
name: "Netflix".into(),
url: Some("https://netflix.com".into()),
username: None,
updated_at: "2026-04-11T00:00:00Z".into(),
},
);
let results = manifest.search("github");
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "GitHub");
let results = manifest.search("flix");
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "Netflix");
}
}
- Step 4: Update lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod entry;
pub mod error;
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub use error::{RelicarioError, Result};
- Step 5: Run tests
Run: cargo test -p relicario-core entry
Expected: All 5 entry tests PASS.
- Step 6: Commit
git add crates/relicario-core/src/
git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde"
Task 6: Vault Operations
Files:
-
Create:
crates/relicario-core/src/vault.rs -
Modify:
crates/relicario-core/src/lib.rs -
Step 1: Write failing tests
// crates/relicario-core/src/vault.rs
// ... (implementation in step 3)
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::{Entry, Manifest, ManifestEntry};
#[test]
fn entry_encrypt_decrypt_round_trip() {
let key = [0x42u8; 32];
let entry = Entry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
password: "secret123".into(),
notes: None,
totp_secret: None,
created_at: "2026-04-11T00:00:00Z".into(),
updated_at: "2026-04-11T00:00:00Z".into(),
};
let encrypted = encrypt_entry(&key, &entry).unwrap();
let decrypted = decrypt_entry(&key, &encrypted).unwrap();
assert_eq!(decrypted.name, "GitHub");
assert_eq!(decrypted.password, "secret123");
}
#[test]
fn manifest_encrypt_decrypt_round_trip() {
let key = [0x42u8; 32];
let mut manifest = Manifest::new();
manifest.add_entry(
"abc123".into(),
ManifestEntry {
name: "Test".into(),
url: None,
username: None,
updated_at: "2026-04-11T00:00:00Z".into(),
},
);
let encrypted = encrypt_manifest(&key, &manifest).unwrap();
let decrypted = decrypt_manifest(&key, &encrypted).unwrap();
assert_eq!(decrypted.entries.len(), 1);
assert!(decrypted.entries.contains_key("abc123"));
}
#[test]
fn entry_wrong_key_fails() {
let key = [0x42u8; 32];
let wrong_key = [0x43u8; 32];
let entry = Entry {
name: "Test".into(),
url: None,
username: None,
password: "pass".into(),
notes: None,
totp_secret: None,
created_at: "2026-04-11T00:00:00Z".into(),
updated_at: "2026-04-11T00:00:00Z".into(),
};
let encrypted = encrypt_entry(&key, &entry).unwrap();
assert!(decrypt_entry(&wrong_key, &encrypted).is_err());
}
}
- Step 2: Run tests to verify they fail
Run: cargo test -p relicario-core vault
Expected: FAIL — functions not defined.
- Step 3: Write the implementation
// crates/relicario-core/src/vault.rs
use crate::crypto;
use crate::entry::{Entry, Manifest};
use crate::error::Result;
/// Encrypt an Entry to bytes (JSON serialized, then encrypted).
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 bytes back to an Entry.
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)
}
/// Encrypt the Manifest to bytes.
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 bytes back to a Manifest.
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::{Entry, Manifest, ManifestEntry};
#[test]
fn entry_encrypt_decrypt_round_trip() {
let key = [0x42u8; 32];
let entry = Entry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
password: "secret123".into(),
notes: None,
totp_secret: None,
created_at: "2026-04-11T00:00:00Z".into(),
updated_at: "2026-04-11T00:00:00Z".into(),
};
let encrypted = encrypt_entry(&key, &entry).unwrap();
let decrypted = decrypt_entry(&key, &encrypted).unwrap();
assert_eq!(decrypted.name, "GitHub");
assert_eq!(decrypted.password, "secret123");
}
#[test]
fn manifest_encrypt_decrypt_round_trip() {
let key = [0x42u8; 32];
let mut manifest = Manifest::new();
manifest.add_entry(
"abc123".into(),
ManifestEntry {
name: "Test".into(),
url: None,
username: None,
updated_at: "2026-04-11T00:00:00Z".into(),
},
);
let encrypted = encrypt_manifest(&key, &manifest).unwrap();
let decrypted = decrypt_manifest(&key, &encrypted).unwrap();
assert_eq!(decrypted.entries.len(), 1);
assert!(decrypted.entries.contains_key("abc123"));
}
#[test]
fn entry_wrong_key_fails() {
let key = [0x42u8; 32];
let wrong_key = [0x43u8; 32];
let entry = Entry {
name: "Test".into(),
url: None,
username: None,
password: "pass".into(),
notes: None,
totp_secret: None,
created_at: "2026-04-11T00:00:00Z".into(),
updated_at: "2026-04-11T00:00:00Z".into(),
};
let encrypted = encrypt_entry(&key, &entry).unwrap();
assert!(decrypt_entry(&wrong_key, &encrypted).is_err());
}
}
- Step 4: Update lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod entry;
pub mod error;
pub mod vault;
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub use error::{RelicarioError, Result};
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
- Step 5: Run all tests
Run: cargo test -p relicario-core
Expected: All tests PASS (KDF + encrypt/decrypt + entry + vault).
- Step 6: Commit
git add crates/relicario-core/src/
git commit -m "feat: add vault encrypt/decrypt for entries and manifest"
Task 7: imgsecret — JPEG Decode, Y Channel, Block DCT
Files:
- Create:
crates/relicario-core/src/imgsecret.rs - Modify:
crates/relicario-core/src/lib.rs
This task builds the image-processing foundation. No embedding yet — just: load JPEG → extract luminance → divide into 8×8 blocks → DCT forward/inverse.
- Step 1: Write tests for DCT round-trip and Y channel extraction
// crates/relicario-core/src/imgsecret.rs
// ... (implementation in step 3)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dct2_idct2_round_trip() {
let block: [f64; 64] = {
let mut b = [0.0; 64];
for i in 0..64 {
b[i] = (i as f64) * 3.7 - 100.0;
}
b
};
let dct = dct2_8x8(&block);
let recovered = idct2_8x8(&dct);
for i in 0..64 {
assert!(
(block[i] - recovered[i]).abs() < 1e-6,
"mismatch at index {i}: {} vs {}",
block[i],
recovered[i]
);
}
}
#[test]
fn extract_y_channel_from_synthetic_jpeg() {
let jpeg_bytes = make_test_jpeg(64, 64);
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
assert_eq!(y_channel.width, 64);
assert_eq!(y_channel.height, 64);
assert_eq!(y_channel.data.len(), 64 * 64);
}
#[test]
fn get_blocks_from_region() {
let jpeg_bytes = make_test_jpeg(80, 80);
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
// Central 70% of 80px = 56px. With 15% margin each side = 12px offset.
// 56/8 = 7 blocks per dimension, 49 blocks total in central region.
let region = central_region(&y_channel);
assert!(region.blocks_x > 0);
assert!(region.blocks_y > 0);
}
/// Create a synthetic JPEG for testing.
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgb, ImageEncoder};
use image::codecs::jpeg::JpegEncoder;
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
}
}
- Step 2: Run tests to verify they fail
Run: cargo test -p relicario-core imgsecret
Expected: FAIL — functions not defined.
- Step 3: Write the implementation
// crates/relicario-core/src/imgsecret.rs
use crate::error::{RelicarioError, Result};
use image::io::Reader as ImageReader;
use std::f64::consts::PI;
use std::io::Cursor;
const BLOCK_SIZE: usize = 8;
/// Y (luminance) channel data extracted from a JPEG.
pub struct YChannel {
pub data: Vec<f64>,
pub width: usize,
pub height: usize,
}
/// Describes the central embedding region and its block grid.
pub struct EmbedRegion {
pub x_offset: usize,
pub y_offset: usize,
pub region_width: usize,
pub region_height: usize,
pub blocks_x: usize,
pub blocks_y: usize,
}
/// Extract the Y (luminance) channel from JPEG bytes.
pub fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
.with_guessed_format()
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
// ITU-R BT.601 luminance conversion
let mut y_data = Vec::with_capacity(width * height);
for pixel in rgb.pixels() {
let r = pixel[0] as f64;
let g = pixel[1] as f64;
let b = pixel[2] as f64;
y_data.push(0.299 * r + 0.587 * g + 0.114 * b);
}
Ok(YChannel {
data: y_data,
width,
height,
})
}
/// Calculate the central 70% embedding region (15% margin on each side).
pub fn central_region(y: &YChannel) -> EmbedRegion {
let margin_x = y.width * 15 / 100;
let margin_y = y.height * 15 / 100;
let x_offset = margin_x;
let y_offset = margin_y;
let region_width = y.width - 2 * margin_x;
let region_height = y.height - 2 * margin_y;
let blocks_x = region_width / BLOCK_SIZE;
let blocks_y = region_height / BLOCK_SIZE;
EmbedRegion {
x_offset,
y_offset,
region_width,
region_height,
blocks_x,
blocks_y,
}
}
/// Read an 8×8 block from the Y channel at block coordinates (bx, by)
/// within the given embedding region.
pub fn read_block(y: &YChannel, region: &EmbedRegion, bx: usize, by: usize) -> [f64; 64] {
let mut block = [0.0f64; 64];
let px_x = region.x_offset + bx * BLOCK_SIZE;
let px_y = region.y_offset + by * BLOCK_SIZE;
for row in 0..BLOCK_SIZE {
for col in 0..BLOCK_SIZE {
let idx = (px_y + row) * y.width + (px_x + col);
block[row * BLOCK_SIZE + col] = y.data[idx];
}
}
block
}
/// Write an 8×8 block back to the Y channel.
pub fn write_block(y: &mut YChannel, region: &EmbedRegion, bx: usize, by: usize, block: &[f64; 64]) {
let px_x = region.x_offset + bx * BLOCK_SIZE;
let px_y = region.y_offset + by * BLOCK_SIZE;
for row in 0..BLOCK_SIZE {
for col in 0..BLOCK_SIZE {
let idx = (px_y + row) * y.width + (px_x + col);
y.data[idx] = block[row * BLOCK_SIZE + col].clamp(0.0, 255.0);
}
}
}
/// 2D Type-II DCT on an 8×8 block (separable: rows then columns).
pub fn dct2_8x8(block: &[f64; 64]) -> [f64; 64] {
let mut temp = [0.0f64; 64];
let mut result = [0.0f64; 64];
// DCT on each row
for row in 0..BLOCK_SIZE {
let src_offset = row * BLOCK_SIZE;
let mut row_data = [0.0f64; BLOCK_SIZE];
row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]);
let dct_row = dct1d_8(&row_data);
temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&dct_row);
}
// DCT on each column of the result
for col in 0..BLOCK_SIZE {
let mut col_data = [0.0f64; BLOCK_SIZE];
for row in 0..BLOCK_SIZE {
col_data[row] = temp[row * BLOCK_SIZE + col];
}
let dct_col = dct1d_8(&col_data);
for row in 0..BLOCK_SIZE {
result[row * BLOCK_SIZE + col] = dct_col[row];
}
}
result
}
/// 2D inverse DCT on an 8×8 block.
pub fn idct2_8x8(block: &[f64; 64]) -> [f64; 64] {
let mut temp = [0.0f64; 64];
let mut result = [0.0f64; 64];
// IDCT on each row
for row in 0..BLOCK_SIZE {
let src_offset = row * BLOCK_SIZE;
let mut row_data = [0.0f64; BLOCK_SIZE];
row_data.copy_from_slice(&block[src_offset..src_offset + BLOCK_SIZE]);
let idct_row = idct1d_8(&row_data);
temp[src_offset..src_offset + BLOCK_SIZE].copy_from_slice(&idct_row);
}
// IDCT on each column
for col in 0..BLOCK_SIZE {
let mut col_data = [0.0f64; BLOCK_SIZE];
for row in 0..BLOCK_SIZE {
col_data[row] = temp[row * BLOCK_SIZE + col];
}
let idct_col = idct1d_8(&col_data);
for row in 0..BLOCK_SIZE {
result[row * BLOCK_SIZE + col] = idct_col[row];
}
}
result
}
/// 1D Type-II DCT for N=8 (orthonormal).
fn dct1d_8(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
let n = BLOCK_SIZE as f64;
for k in 0..BLOCK_SIZE {
let mut sum = 0.0;
for i in 0..BLOCK_SIZE {
sum += input[i] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos();
}
let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() };
output[k] = ck * sum;
}
output
}
/// 1D inverse DCT for N=8 (orthonormal).
fn idct1d_8(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
let n = BLOCK_SIZE as f64;
for i in 0..BLOCK_SIZE {
let mut sum = 0.0;
for k in 0..BLOCK_SIZE {
let ck = if k == 0 { (1.0 / n).sqrt() } else { (2.0 / n).sqrt() };
sum += ck * input[k] * ((2.0 * i as f64 + 1.0) * k as f64 * PI / (2.0 * n)).cos();
}
output[i] = sum;
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dct2_idct2_round_trip() {
let block: [f64; 64] = {
let mut b = [0.0; 64];
for i in 0..64 {
b[i] = (i as f64) * 3.7 - 100.0;
}
b
};
let dct = dct2_8x8(&block);
let recovered = idct2_8x8(&dct);
for i in 0..64 {
assert!(
(block[i] - recovered[i]).abs() < 1e-6,
"mismatch at index {i}: {} vs {}",
block[i],
recovered[i]
);
}
}
#[test]
fn extract_y_channel_from_synthetic_jpeg() {
let jpeg_bytes = make_test_jpeg(64, 64);
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
assert_eq!(y_channel.width, 64);
assert_eq!(y_channel.height, 64);
assert_eq!(y_channel.data.len(), 64 * 64);
}
#[test]
fn get_blocks_from_region() {
let jpeg_bytes = make_test_jpeg(80, 80);
let y_channel = extract_y_channel(&jpeg_bytes).unwrap();
let region = central_region(&y_channel);
assert!(region.blocks_x > 0);
assert!(region.blocks_y > 0);
}
#[test]
fn read_write_block_round_trip() {
let jpeg_bytes = make_test_jpeg(80, 80);
let mut y_channel = extract_y_channel(&jpeg_bytes).unwrap();
let region = central_region(&y_channel);
let original = read_block(&y_channel, ®ion, 0, 0);
write_block(&mut y_channel, ®ion, 0, 0, &original);
let recovered = read_block(&y_channel, ®ion, 0, 0);
for i in 0..64 {
assert!((original[i] - recovered[i]).abs() < 1e-10);
}
}
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
}
}
- Step 4: Update lib.rs
// crates/relicario-core/src/lib.rs
pub mod crypto;
pub mod entry;
pub mod error;
pub mod imgsecret;
pub mod vault;
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub use error::{RelicarioError, Result};
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
- Step 5: Run tests
Run: cargo test -p relicario-core imgsecret
Expected: All 4 tests PASS.
- Step 6: Commit
git add crates/relicario-core/src/
git commit -m "feat: add imgsecret JPEG decode, Y channel extraction, and 8x8 DCT"
Task 8: imgsecret — QIM Embedding + Block Selection
Files:
- Modify:
crates/relicario-core/src/imgsecret.rs
This task adds QIM (Quantization Index Modulation) for embedding/extracting individual bits in DCT coefficients, and the fixed geometric pattern for selecting which blocks carry data.
- Step 1: Write tests for QIM and block selection
Add to mod tests in imgsecret.rs:
#[test]
fn qim_embed_extract_single_bit() {
for coef in [-50.0, -10.0, 0.0, 10.0, 50.0, 127.0] {
for bit in [0u8, 1] {
let modified = qim_embed(coef, bit, QUANT_STEP);
let extracted = qim_extract(modified, QUANT_STEP);
assert_eq!(extracted, bit, "failed for coef={coef}, bit={bit}");
}
}
}
#[test]
fn qim_survives_small_noise() {
let coef = 42.0;
let modified = qim_embed(coef, 1, QUANT_STEP);
// Add noise smaller than QUANT_STEP/4
let noisy = modified + 3.0;
let extracted = qim_extract(noisy, QUANT_STEP);
assert_eq!(extracted, 1);
}
#[test]
fn select_embed_blocks_returns_consistent_pattern() {
let region = EmbedRegion {
x_offset: 12,
y_offset: 9,
region_width: 56,
region_height: 56,
blocks_x: 7,
blocks_y: 7,
};
let blocks1 = select_embed_blocks(®ion, 100);
let blocks2 = select_embed_blocks(®ion, 100);
assert_eq!(blocks1, blocks2, "pattern must be deterministic");
assert!(!blocks1.is_empty());
}
- Step 2: Run tests to verify they fail
Run: cargo test -p relicario-core qim
Expected: FAIL — qim_embed, qim_extract, select_embed_blocks, QUANT_STEP not defined.
- Step 3: Write QIM and block selection implementation
Add above the #[cfg(test)] block in imgsecret.rs:
/// QIM quantization step. Larger = more robust, more visible.
/// 25 survives JPEG recompression down to ~Q60 on most images.
const QUANT_STEP: f64 = 25.0;
/// Mid-frequency DCT coefficient positions in zig-zag order.
/// Positions 4-15: robust to recompression, visually undetectable.
/// Index into the 8×8 block as (row, col).
const EMBED_POSITIONS: [(usize, usize); 12] = [
(0, 3), // zig-zag position 4
(1, 2), // 5
(2, 1), // 6
(3, 0), // 7
(0, 4), // 8
(1, 3), // 9
(2, 2), // 10
(3, 1), // 11
(4, 0), // 12
(0, 5), // 13
(1, 4), // 14
(2, 3), // 15
];
/// Embed a single bit into a DCT coefficient using QIM.
///
/// Grid 0: values quantized to 0, Q, 2Q, 3Q, ...
/// Grid 1: values quantized to Q/2, 3Q/2, 5Q/2, ...
/// To embed bit b, quantize to grid b.
pub fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
let half_q = q / 2.0;
let offset = if bit == 1 { half_q } else { 0.0 };
let shifted = coef - offset;
let quantized = (shifted / q).round() * q;
quantized + offset
}
/// Extract a single bit from a DCT coefficient using QIM.
///
/// Measures distance to grid 0 and grid 1, returns whichever is closer.
pub fn qim_extract(coef: f64, q: f64) -> u8 {
let half_q = q / 2.0;
let dist0 = (coef - (coef / q).round() * q).abs();
let shifted = coef - half_q;
let dist1 = (shifted - (shifted / q).round() * q).abs();
if dist0 <= dist1 { 0 } else { 1 }
}
/// Select embedding block positions using a fixed geometric pattern.
///
/// Evenly spaced across the central region. Returns (bx, by) pairs.
/// `target_count` is the desired number of blocks; actual may be less
/// if the region is small.
pub fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
let total_blocks = region.blocks_x * region.blocks_y;
let count = target_count.min(total_blocks);
if count == 0 {
return vec![];
}
// Compute stride to evenly space `count` blocks across the grid
let stride = if count >= total_blocks {
1
} else {
total_blocks / count
};
let mut positions = Vec::with_capacity(count);
for i in (0..total_blocks).step_by(stride) {
let bx = i % region.blocks_x;
let by = i / region.blocks_x;
positions.push((bx, by));
if positions.len() >= count {
break;
}
}
positions
}
- Step 4: Run tests
Run: cargo test -p relicario-core imgsecret
Expected: All tests PASS (previous 4 + 3 new QIM/block-selection tests).
- Step 5: Commit
git add crates/relicario-core/src/imgsecret.rs
git commit -m "feat: add QIM bit embedding and fixed-pattern block selection"
Task 9: imgsecret — Full embed() and extract()
Files:
- Modify:
crates/relicario-core/src/imgsecret.rs
This is the main event: the public embed() and extract() functions with redundancy coding and majority voting. Reed-Solomon is added in Task 10.
- Step 1: Write the failing test for round-trip embed/extract
Add to mod tests:
#[test]
fn embed_extract_round_trip() {
let jpeg_bytes = make_test_jpeg(400, 300);
let secret = [0xDEu8; 32];
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
let extracted = extract(&stego_jpeg).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn embed_extract_random_secret() {
let jpeg_bytes = make_test_jpeg(400, 300);
let mut secret = [0u8; 32];
rand::thread_rng().fill(&mut secret);
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
let extracted = extract(&stego_jpeg).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn extract_from_non_embedded_image_fails() {
let jpeg_bytes = make_test_jpeg(400, 300);
let result = extract(&jpeg_bytes);
assert!(result.is_err());
}
#[test]
fn image_too_small_fails() {
let jpeg_bytes = make_test_jpeg(32, 32);
let secret = [0xABu8; 32];
let result = embed(&jpeg_bytes, &secret);
assert!(result.is_err());
}
Add use rand::Fill; at the top of the test module for the random fill.
- Step 2: Run tests to verify they fail
Run: cargo test -p relicario-core embed_extract
Expected: FAIL — embed and extract not defined.
- Step 3: Write embed() implementation
Add to imgsecret.rs:
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
/// Number of bits per redundant copy of the secret.
const SECRET_BITS: usize = 256; // 32 bytes
/// Minimum number of redundant copies for reliable extraction.
const MIN_COPIES: usize = 5;
/// Bits per embedding block (number of mid-freq coefficients used).
const BITS_PER_BLOCK: usize = EMBED_POSITIONS.len(); // 12
/// Blocks needed for one copy of the secret.
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // ceil(256/12) = 22
/// Minimum image dimension for embedding.
const MIN_DIMENSION: u32 = 100;
/// Embed a 256-bit secret into a carrier JPEG.
/// Returns the modified JPEG bytes.
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let mut y = extract_y_channel(carrier_jpeg)?;
let region = central_region(&y);
// Check minimum size
if y.width < MIN_DIMENSION as usize || y.height < MIN_DIMENSION as usize {
return Err(RelicarioError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
actual_height: y.height as u32,
});
}
let total_blocks = region.blocks_x * region.blocks_y;
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); // cap at 50 copies
if num_copies < MIN_COPIES {
return Err(RelicarioError::ImgSecret(format!(
"image too small for embedding: only {num_copies} copies fit, need at least {MIN_COPIES}"
)));
}
// Convert secret to bits
let secret_bits = bytes_to_bits(secret);
// Get all embed block positions
let blocks_needed = num_copies * BLOCKS_PER_COPY;
let positions = select_embed_blocks(®ion, blocks_needed);
// Embed each redundant copy
for copy_idx in 0..num_copies {
let block_start = copy_idx * BLOCKS_PER_COPY;
for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY]
.iter()
.enumerate()
{
let mut block = read_block(&y, ®ion, bx, by);
let mut dct = dct2_8x8(&block);
// Embed up to BITS_PER_BLOCK bits in this block
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx;
if bit_idx < SECRET_BITS {
let coef_idx = row * BLOCK_SIZE + col;
dct[coef_idx] = qim_embed(dct[coef_idx], secret_bits[bit_idx], QUANT_STEP);
}
}
block = idct2_8x8(&dct);
write_block(&mut y, ®ion, bx, by, &block);
}
}
// Reconstruct full RGB image with modified Y channel and save as JPEG
reconstruct_jpeg(carrier_jpeg, &y)
}
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
extract_at_offset(jpeg_bytes, 0, 0)
}
/// Try extraction at a specific pixel offset (for crop recovery).
fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]> {
let y = extract_y_channel(jpeg_bytes)?;
let mut region = central_region(&y);
// Apply offset for crop recovery
let new_x = region.x_offset as isize + dx;
let new_y = region.y_offset as isize + dy;
if new_x < 0 || new_y < 0 {
return Err(RelicarioError::ExtractionFailed);
}
region.x_offset = new_x as usize;
region.y_offset = new_y as usize;
// Recalculate blocks that fit
let avail_w = y.width.saturating_sub(region.x_offset);
let avail_h = y.height.saturating_sub(region.y_offset);
region.blocks_x = avail_w / BLOCK_SIZE;
region.blocks_y = avail_h / BLOCK_SIZE;
let total_blocks = region.blocks_x * region.blocks_y;
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
if num_copies < 1 {
return Err(RelicarioError::ExtractionFailed);
}
let blocks_needed = num_copies * BLOCKS_PER_COPY;
let positions = select_embed_blocks(®ion, blocks_needed);
// Extract all copies and majority-vote each bit
let mut bit_votes = vec![[0u32; 2]; SECRET_BITS]; // votes[bit_idx][0 or 1]
for copy_idx in 0..num_copies {
let block_start = copy_idx * BLOCKS_PER_COPY;
if block_start + BLOCKS_PER_COPY > positions.len() {
break;
}
for (bit_block, &(bx, by)) in positions[block_start..block_start + BLOCKS_PER_COPY]
.iter()
.enumerate()
{
let block = read_block(&y, ®ion, bx, by);
let dct = dct2_8x8(&block);
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = bit_block * BITS_PER_BLOCK + pos_idx;
if bit_idx < SECRET_BITS {
let coef_idx = row * BLOCK_SIZE + col;
let bit = qim_extract(dct[coef_idx], QUANT_STEP);
bit_votes[bit_idx][bit as usize] += 1;
}
}
}
}
// Majority vote
let mut secret_bits = vec![0u8; SECRET_BITS];
let mut confidence = 0u32;
for (i, votes) in bit_votes.iter().enumerate() {
if votes[1] > votes[0] {
secret_bits[i] = 1;
confidence += votes[1];
} else {
confidence += votes[0];
}
}
// Confidence check: if votes are too evenly split, extraction likely failed
let total_votes: u32 = bit_votes.iter().map(|v| v[0] + v[1]).sum();
let min_confidence = total_votes * 3 / 4; // at least 75% of votes should agree
if confidence < min_confidence {
return Err(RelicarioError::ExtractionFailed);
}
Ok(bits_to_bytes(&secret_bits))
}
// ---- Helper functions ----
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
let mut bits = Vec::with_capacity(bytes.len() * 8);
for &byte in bytes {
for i in (0..8).rev() {
bits.push((byte >> i) & 1);
}
}
bits
}
fn bits_to_bytes(bits: &[u8]) -> [u8; 32] {
let mut bytes = [0u8; 32];
for (i, chunk) in bits.chunks(8).enumerate() {
if i >= 32 {
break;
}
let mut byte = 0u8;
for (j, &bit) in chunk.iter().enumerate() {
byte |= bit << (7 - j);
}
bytes[i] = byte;
}
bytes
}
/// Reconstruct JPEG from original image with modified Y channel.
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
let reader = ImageReader::new(Cursor::new(original_jpeg))
.with_guessed_format()
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width(), rgb.height());
// Reconstruct RGB: replace Y while keeping original Cb/Cr
let mut output: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::new(width, height);
for (x, y_pos, pixel) in rgb.enumerate_pixels() {
let idx = y_pos as usize * width as usize + x as usize;
let orig_r = pixel[0] as f64;
let orig_g = pixel[1] as f64;
let orig_b = pixel[2] as f64;
// Original YCbCr
let orig_y = 0.299 * orig_r + 0.587 * orig_g + 0.114 * orig_b;
let cb = 128.0 - 0.168736 * orig_r - 0.331264 * orig_g + 0.5 * orig_b;
let cr = 128.0 + 0.5 * orig_r - 0.418688 * orig_g - 0.081312 * orig_b;
// Modified Y
let new_y = y_modified.data[idx];
// YCbCr → RGB with new Y
let r = (new_y + 1.402 * (cr - 128.0)).clamp(0.0, 255.0) as u8;
let g = (new_y - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0)).clamp(0.0, 255.0) as u8;
let b = (new_y + 1.772 * (cb - 128.0)).clamp(0.0, 255.0) as u8;
output.put_pixel(x, y_pos, Rgb([r, g, b]));
}
// Encode as JPEG at quality 92
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
Ok(buf)
}
- Step 4: Run tests
Run: cargo test -p relicario-core imgsecret -- --nocapture
Expected: All tests PASS including embed/extract round-trip.
- Step 5: Add a JPEG recompression survival test
Add to mod tests:
#[test]
fn embed_extract_survives_recompression_q85() {
let jpeg_bytes = make_test_jpeg(400, 300);
let secret = [0xCAu8; 32];
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
// Re-encode at Q85 (simulating social media)
let reader = image::io::Reader::new(Cursor::new(&stego_jpeg))
.with_guessed_format()
.unwrap();
let img = reader.decode().unwrap();
let rgb = img.to_rgb8();
let (w, h) = (rgb.width(), rgb.height());
let mut recompressed = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut recompressed, 85);
encoder
.write_image(rgb.as_raw(), w, h, image::ExtendedColorType::Rgb8)
.unwrap();
let extracted = extract(&recompressed).unwrap();
assert_eq!(extracted, secret, "secret must survive Q85 recompression");
}
- Step 6: Run all tests
Run: cargo test -p relicario-core
Expected: All tests PASS.
- Step 7: Commit
git add crates/relicario-core/src/imgsecret.rs
git commit -m "feat: add imgsecret embed/extract with redundancy and majority voting"
Task 10: imgsecret — Crop Recovery
Files:
-
Modify:
crates/relicario-core/src/imgsecret.rs -
Step 1: Write failing crop test
Add to mod tests:
#[test]
fn embed_extract_survives_10pct_crop() {
let jpeg_bytes = make_test_jpeg(400, 300);
let secret = [0xBBu8; 32];
let stego_jpeg = embed(&jpeg_bytes, &secret).unwrap();
// Crop 10% from the right edge
let reader = image::io::Reader::new(Cursor::new(&stego_jpeg))
.with_guessed_format()
.unwrap();
let img = reader.decode().unwrap();
let (w, h) = (img.width(), img.height());
let crop_pixels = w * 10 / 100;
let cropped = img.crop_imm(0, 0, w - crop_pixels, h);
let rgb = cropped.to_rgb8();
let mut cropped_jpeg = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut cropped_jpeg, 92);
encoder
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.unwrap();
let extracted = extract_with_crop_recovery(&cropped_jpeg).unwrap();
assert_eq!(extracted, secret, "secret must survive 10% crop");
}
- Step 2: Run test to verify it fails
Run: cargo test -p relicario-core crop
Expected: FAIL — extract_with_crop_recovery not defined.
- Step 3: Write crop recovery implementation
Add to imgsecret.rs:
/// Extract with crop recovery — tries multiple offsets if canonical extraction fails.
pub fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
// Try canonical alignment first (fast path)
if let Ok(secret) = extract(jpeg_bytes) {
return Ok(secret);
}
// Determine search range from image dimensions
let y = extract_y_channel(jpeg_bytes)?;
let max_dx = (y.width as isize) * 15 / 100;
let max_dy = (y.height as isize) * 15 / 100;
let step = BLOCK_SIZE as isize; // 8 pixels
// Search crop offsets
for dy in (-max_dy..=max_dy).step_by(step as usize) {
for dx in (-max_dx..=max_dx).step_by(step as usize) {
if dx == 0 && dy == 0 {
continue; // already tried canonical
}
if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) {
return Ok(secret);
}
}
}
Err(RelicarioError::ExtractionFailed)
}
Also update the public extract() to call extract_with_crop_recovery():
Replace the existing extract function:
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
/// Automatically tries crop recovery if canonical extraction fails.
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
extract_with_crop_recovery(jpeg_bytes)
}
/// Internal: try extraction at canonical (0,0) offset only.
fn extract_canonical(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
extract_at_offset(jpeg_bytes, 0, 0)
}
/// Extract with crop recovery — tries multiple offsets if canonical extraction fails.
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
// Try canonical alignment first (fast path)
if let Ok(secret) = extract_canonical(jpeg_bytes) {
return Ok(secret);
}
// Determine search range from image dimensions
let y = extract_y_channel(jpeg_bytes)?;
let max_dx = (y.width as isize) * 15 / 100;
let max_dy = (y.height as isize) * 15 / 100;
let step = BLOCK_SIZE as isize;
for dy in (-max_dy..=max_dy).step_by(step as usize) {
for dx in (-max_dx..=max_dx).step_by(step as usize) {
if dx == 0 && dy == 0 {
continue;
}
if let Ok(secret) = extract_at_offset(jpeg_bytes, dx, dy) {
return Ok(secret);
}
}
}
Err(RelicarioError::ExtractionFailed)
}
- Step 4: Run all imgsecret tests
Run: cargo test -p relicario-core imgsecret -- --nocapture
Expected: All tests PASS including crop recovery.
- Step 5: Commit
git add crates/relicario-core/src/imgsecret.rs
git commit -m "feat: add crop recovery with multi-offset extraction search"
Task 11: CLI — Scaffolding, init, generate
Files:
-
Modify:
crates/relicario-cli/src/main.rs -
Step 1: Write the clap CLI structure
// crates/relicario-cli/src/main.rs
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use relicario_core::{
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Parser)]
#[command(name = "relicario", version, about = "Git-backed password manager with reference image authentication")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Create a new vault
Init {
/// Path to carrier JPEG image
#[arg(long)]
image: PathBuf,
/// Output path for the reference JPEG (with embedded secret)
#[arg(long, default_value = "reference.jpg")]
output: PathBuf,
},
/// Add a new credential
Add,
/// Get a credential by name
Get {
/// Name or URL to search for
name: String,
},
/// List all credentials
List,
/// Edit a credential
Edit {
/// Name or URL to search for
name: String,
},
/// Remove a credential
Rm {
/// Name or URL to search for
name: String,
},
/// Sync with remote (git pull + push)
Sync,
/// Generate a random password
Generate {
/// Password length
#[arg(short, long, default_value = "20")]
length: usize,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { image, output } => cmd_init(&image, &output),
Commands::Add => cmd_add(),
Commands::Get { name } => cmd_get(&name),
Commands::List => cmd_list(),
Commands::Edit { name } => cmd_edit(&name),
Commands::Rm { name } => cmd_rm(&name),
Commands::Sync => cmd_sync(),
Commands::Generate { length } => cmd_generate(length),
}
}
fn cmd_generate(length: usize) -> Result<()> {
use rand::Rng;
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
let mut rng = rand::thread_rng();
let password: String = (0..length)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();
println!("{password}");
Ok(())
}
// ---- Vault helpers ----
/// Path to the vault root (current directory by default).
fn vault_dir() -> PathBuf {
PathBuf::from(".")
}
fn relicario_dir() -> PathBuf {
vault_dir().join(".relicario")
}
fn read_salt() -> Result<[u8; 32]> {
let bytes = fs::read(relicario_dir().join("salt"))
.context("failed to read .relicario/salt — is this a vault directory?")?;
let mut salt = [0u8; 32];
salt.copy_from_slice(&bytes);
Ok(salt)
}
fn read_params() -> Result<KdfParams> {
let json = fs::read_to_string(relicario_dir().join("params.json"))
.context("failed to read .relicario/params.json")?;
Ok(serde_json::from_str(&json)?)
}
/// Prompt for passphrase and reference image, derive master_key.
fn unlock(image_path: &Path) -> Result<[u8; 32]> {
let passphrase = rpassword::prompt_password("Passphrase: ")
.context("failed to read passphrase")?;
let jpeg_bytes = fs::read(image_path)
.context("failed to read reference image")?;
let image_secret = relicario_core::imgsecret::extract(&jpeg_bytes)
.map_err(|e| anyhow::anyhow!("failed to extract image secret: {e}"))?;
let salt = read_salt()?;
let params = read_params()?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
Ok(master_key)
}
/// Get reference image path — from env var RELICARIO_IMAGE or prompt.
fn get_image_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
return Ok(PathBuf::from(path));
}
eprint!("Reference image path: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(PathBuf::from(input.trim()))
}
fn read_manifest(master_key: &[u8; 32]) -> Result<Manifest> {
let data = fs::read(vault_dir().join("manifest.enc"))
.context("failed to read manifest.enc")?;
decrypt_manifest(master_key, &data)
.map_err(|e| anyhow::anyhow!("failed to decrypt manifest: {e}"))
}
fn write_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<()> {
let data = encrypt_manifest(master_key, manifest)
.map_err(|e| anyhow::anyhow!("failed to encrypt manifest: {e}"))?;
fs::write(vault_dir().join("manifest.enc"), data)?;
Ok(())
}
fn git_commit(message: &str) -> Result<()> {
Command::new("git")
.args(["add", "-A"])
.status()
.context("git add failed")?;
Command::new("git")
.args(["commit", "-m", message])
.status()
.context("git commit failed")?;
Ok(())
}
fn now_iso8601() -> String {
// Simple UTC timestamp without chrono dependency
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap();
let secs = duration.as_secs();
// Rough ISO 8601 — good enough for a password manager
format!("{secs}")
}
// ---- Command implementations ----
fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
// 1. Read carrier image
let carrier_jpeg = fs::read(image_path)
.context("failed to read carrier image")?;
// 2. Generate image_secret and embed
let mut image_secret = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret);
println!("Embedding secret into reference image...");
let stego_jpeg = relicario_core::imgsecret::embed(&carrier_jpeg, &image_secret)
.map_err(|e| anyhow::anyhow!("failed to embed secret: {e}"))?;
fs::write(output_path, &stego_jpeg)
.context("failed to write reference image")?;
println!("Reference image saved to: {}", output_path.display());
// 3. Prompt for passphrase
let passphrase = rpassword::prompt_password("Choose a passphrase: ")?;
let passphrase_confirm = rpassword::prompt_password("Confirm passphrase: ")?;
if passphrase != passphrase_confirm {
anyhow::bail!("passphrases do not match");
}
if passphrase.len() < 8 {
anyhow::bail!("passphrase must be at least 8 characters");
}
// 4. Generate salt and derive key
let mut salt = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
let params = KdfParams::default();
println!("Deriving master key (this may take a moment)...");
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
// 5. Write vault structure
fs::create_dir_all(relicario_dir())?;
fs::create_dir_all(vault_dir().join("entries"))?;
fs::write(relicario_dir().join("salt"), salt)?;
fs::write(
relicario_dir().join("params.json"),
serde_json::to_string_pretty(¶ms)?,
)?;
fs::write(relicario_dir().join("devices.json"), "[]")?;
// 6. Write empty manifest
let manifest = Manifest::new();
write_manifest(&master_key, &manifest)?;
// 7. Git init + commit
Command::new("git").args(["init"]).status()?;
// Add .gitignore
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")?;
git_commit("feat: initialize relicario vault")?;
println!("\nVault initialized successfully!");
println!("IMPORTANT: Keep your reference image ({}) safe — you need it to unlock the vault.", output_path.display());
Ok(())
}
fn cmd_add() -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let mut manifest = read_manifest(&master_key)?;
// Prompt for entry fields
let name = prompt("Name (e.g., GitHub): ")?;
let url = prompt_optional("URL: ")?;
let username = prompt_optional("Username: ")?;
let password = rpassword::prompt_password("Password (or press Enter to generate): ")?;
let password = if password.is_empty() {
let p = generate_password(20);
println!("Generated: {p}");
p
} else {
password
};
let notes = prompt_optional("Notes: ")?;
let totp = prompt_optional("TOTP secret: ")?;
let now = now_iso8601();
let entry = Entry {
name: name.clone(),
url: url.clone(),
username: username.clone(),
password,
notes,
totp_secret: totp,
created_at: now.clone(),
updated_at: now.clone(),
};
let entry_id = generate_entry_id();
let encrypted = encrypt_entry(&master_key, &entry)
.map_err(|e| anyhow::anyhow!("failed to encrypt entry: {e}"))?;
fs::write(vault_dir().join("entries").join(format!("{entry_id}.enc")), encrypted)?;
manifest.add_entry(
entry_id.clone(),
ManifestEntry {
name: name.clone(),
url,
username,
updated_at: now,
},
);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("add: {name}"))?;
println!("Added entry: {name} ({entry_id})");
Ok(())
}
fn cmd_get(query: &str) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let results = manifest.search(query);
if results.is_empty() {
anyhow::bail!("no entries matching '{query}'");
}
let (id, meta) = if results.len() == 1 {
results[0]
} else {
println!("Multiple matches:");
for (i, (id, e)) in results.iter().enumerate() {
println!(" {}: {} ({})", i + 1, e.name, id);
}
let choice = prompt("Choose (number): ")?;
let idx: usize = choice.trim().parse().context("invalid number")?;
results.get(idx - 1).context("invalid choice")?
};
let entry_data = fs::read(vault_dir().join("entries").join(format!("{id}.enc")))?;
let entry = decrypt_entry(&master_key, &entry_data)
.map_err(|e| anyhow::anyhow!("failed to decrypt entry: {e}"))?;
println!("Name: {}", entry.name);
if let Some(ref url) = entry.url {
println!("URL: {url}");
}
if let Some(ref user) = entry.username {
println!("Username: {user}");
}
println!("Password: {}", entry.password);
if let Some(ref notes) = entry.notes {
println!("Notes: {notes}");
}
// Copy to clipboard
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if clipboard.set_text(&entry.password).is_ok() {
println!("\n(Password copied to clipboard — clears in 30s)");
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set_text("");
}
});
}
}
Ok(())
}
fn cmd_list() -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
if manifest.entries.is_empty() {
println!("Vault is empty.");
return Ok(());
}
let mut entries: Vec<_> = manifest.entries.iter().collect();
entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()));
for (id, entry) in entries {
let url = entry.url.as_deref().unwrap_or("-");
let user = entry.username.as_deref().unwrap_or("-");
println!("{id} {:<20} {:<30} {user}", entry.name, url);
}
Ok(())
}
fn cmd_edit(query: &str) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let mut manifest = read_manifest(&master_key)?;
let results = manifest.search(query);
if results.is_empty() {
anyhow::bail!("no entries matching '{query}'");
}
let (id, _) = results[0];
let id = id.clone();
let entry_data = fs::read(vault_dir().join("entries").join(format!("{id}.enc")))?;
let mut entry = decrypt_entry(&master_key, &entry_data)
.map_err(|e| anyhow::anyhow!("failed to decrypt entry: {e}"))?;
println!("Editing: {} (press Enter to keep current value)", entry.name);
entry.name = prompt_with_default("Name", &entry.name)?;
entry.url = Some(prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty());
entry.username = Some(prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty());
let new_pass = rpassword::prompt_password("Password (Enter to keep): ")?;
if !new_pass.is_empty() {
entry.password = new_pass;
}
entry.notes = Some(prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?).filter(|s| !s.is_empty());
entry.updated_at = now_iso8601();
let encrypted = encrypt_entry(&master_key, &entry)
.map_err(|e| anyhow::anyhow!("failed to encrypt entry: {e}"))?;
fs::write(vault_dir().join("entries").join(format!("{id}.enc")), encrypted)?;
// Update manifest
if let Some(me) = manifest.entries.get_mut(&id) {
me.name = entry.name.clone();
me.url = entry.url.clone();
me.username = entry.username.clone();
me.updated_at = entry.updated_at.clone();
}
write_manifest(&master_key, &manifest)?;
git_commit(&format!("edit: {}", entry.name))?;
println!("Updated: {}", entry.name);
Ok(())
}
fn cmd_rm(query: &str) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let mut manifest = read_manifest(&master_key)?;
let results = manifest.search(query);
if results.is_empty() {
anyhow::bail!("no entries matching '{query}'");
}
let (id, meta) = results[0];
let id = id.clone();
let name = meta.name.clone();
let confirm = prompt(&format!("Delete '{name}'? (y/N): "))?;
if confirm.trim().to_lowercase() != "y" {
println!("Cancelled.");
return Ok(());
}
fs::remove_file(vault_dir().join("entries").join(format!("{id}.enc")))?;
manifest.remove_entry(&id);
write_manifest(&master_key, &manifest)?;
git_commit(&format!("rm: {name}"))?;
println!("Deleted: {name}");
Ok(())
}
fn cmd_sync() -> Result<()> {
println!("Pulling...");
let pull = Command::new("git")
.args(["pull", "--rebase"])
.status()
.context("git pull failed")?;
if !pull.success() {
anyhow::bail!("git pull failed");
}
println!("Pushing...");
let push = Command::new("git")
.args(["push"])
.status()
.context("git push failed")?;
if !push.success() {
anyhow::bail!("git push failed");
}
println!("Synced.");
Ok(())
}
// ---- Terminal helpers ----
fn prompt(message: &str) -> Result<String> {
eprint!("{message}");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
fn prompt_optional(message: &str) -> Result<Option<String>> {
let input = prompt(message)?;
Ok(if input.is_empty() { None } else { Some(input) })
}
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
let display = if current.is_empty() {
format!("{field}: ")
} else {
format!("{field} [{current}]: ")
};
let input = prompt(&display)?;
Ok(if input.is_empty() {
current.to_string()
} else {
input
})
}
fn generate_password(length: usize) -> String {
use rand::Rng;
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
let mut rng = rand::thread_rng();
(0..length)
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
.collect()
}
- Step 2: Verify build
Run: cargo build
Expected: Compiles with no errors.
- Step 3: Test generate command
Run: cargo run -- generate
Expected: Prints a random 20-character password.
Run: cargo run -- generate -l 32
Expected: Prints a random 32-character password.
- Step 4: Test help output
Run: cargo run -- --help
Expected: Shows all subcommands with descriptions.
- Step 5: Commit
git add crates/relicario-cli/src/main.rs
git commit -m "feat: add full CLI with init, add, get, list, edit, rm, sync, generate"
Task 12: CLI — Device Management
Files:
-
Modify:
crates/relicario-cli/src/main.rs -
Step 1: Add device subcommands to the CLI
Add to the Commands enum:
/// Manage trusted devices
Device {
#[command(subcommand)]
action: DeviceCommands,
},
Add the subcommand enum:
#[derive(Subcommand)]
enum DeviceCommands {
/// Register this device
Add {
/// Device name
#[arg(long)]
name: String,
},
/// List authorized devices
List,
/// Revoke a device
Revoke {
/// Device name to revoke
name: String,
},
}
Add the match arm in main():
Commands::Device { action } => match action {
DeviceCommands::Add { name } => cmd_device_add(&name),
DeviceCommands::List => cmd_device_list(),
DeviceCommands::Revoke { name } => cmd_device_revoke(&name),
},
- Step 2: Write device management functions
Add to main.rs:
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DeviceEntry {
name: String,
public_key: String, // hex-encoded ed25519 public key
}
fn read_devices() -> Result<Vec<DeviceEntry>> {
let json = fs::read_to_string(relicario_dir().join("devices.json"))
.context("failed to read devices.json")?;
Ok(serde_json::from_str(&json)?)
}
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
let json = serde_json::to_string_pretty(devices)?;
fs::write(relicario_dir().join("devices.json"), json)?;
Ok(())
}
fn cmd_device_add(name: &str) -> Result<()> {
let mut devices = read_devices()?;
if devices.iter().any(|d| d.name == name) {
anyhow::bail!("device '{name}' already registered");
}
// Generate ed25519 keypair
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
let verifying_key: VerifyingKey = (&signing_key).into();
let pubkey_hex = hex::encode(verifying_key.as_bytes());
// Save private key to local config
let config_dir = dirs::config_dir()
.context("no config directory")?
.join("relicario");
fs::create_dir_all(&config_dir)?;
fs::write(
config_dir.join(format!("{name}.key")),
hex::encode(signing_key.to_bytes()),
)?;
devices.push(DeviceEntry {
name: name.to_string(),
public_key: pubkey_hex.clone(),
});
write_devices(&devices)?;
git_commit(&format!("device: add {name}"))?;
println!("Device '{name}' registered (pubkey: {pubkey_hex})");
println!("Private key saved to: {}", config_dir.join(format!("{name}.key")).display());
Ok(())
}
fn cmd_device_list() -> Result<()> {
let devices = read_devices()?;
if devices.is_empty() {
println!("No devices registered.");
return Ok(());
}
for d in &devices {
println!(" {} — {}", d.name, &d.public_key[..16]);
}
Ok(())
}
fn cmd_device_revoke(name: &str) -> Result<()> {
let mut devices = read_devices()?;
let before = devices.len();
devices.retain(|d| d.name != name);
if devices.len() == before {
anyhow::bail!("device '{name}' not found");
}
write_devices(&devices)?;
git_commit(&format!("device: revoke {name}"))?;
println!("Device '{name}' revoked.");
Ok(())
}
- Step 3: Add hex dependency
Add to crates/relicario-cli/Cargo.toml under [dependencies]:
hex = "0.4"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
- Step 4: Verify build
Run: cargo build
Expected: Compiles cleanly.
- Step 5: Commit
git add crates/relicario-cli/
git commit -m "feat: add device add/list/revoke commands with ed25519 key management"
Task 13: Integration Test — Full Vault Workflow
Files:
- Create:
crates/relicario-core/tests/integration.rs
This test exercises the full flow: generate secret → embed → derive key → encrypt entry → decrypt entry → extract secret from re-encoded image.
- Step 1: Write the integration test
// crates/relicario-core/tests/integration.rs
use relicario_core::*;
use relicario_core::imgsecret;
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
}
#[test]
fn full_vault_workflow() {
// 1. Generate an image_secret and embed it
let carrier = make_test_jpeg(400, 300);
let mut image_secret = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret);
let reference_jpeg = imgsecret::embed(&carrier, &image_secret).unwrap();
// 2. Extract the secret back
let extracted_secret = imgsecret::extract(&reference_jpeg).unwrap();
assert_eq!(extracted_secret, image_secret);
// 3. Derive master key
let passphrase = b"correct horse battery staple";
let mut salt = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
let params = KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
};
let master_key = derive_master_key(passphrase, &extracted_secret, &salt, ¶ms).unwrap();
// 4. Create and encrypt an entry
let entry = Entry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
password: "supersecret123!".into(),
notes: None,
totp_secret: None,
created_at: "2026-04-11T00:00:00Z".into(),
updated_at: "2026-04-11T00:00:00Z".into(),
};
let encrypted = encrypt_entry(&master_key, &entry).unwrap();
// 5. Decrypt and verify
let decrypted = decrypt_entry(&master_key, &encrypted).unwrap();
assert_eq!(decrypted.name, "GitHub");
assert_eq!(decrypted.password, "supersecret123!");
assert_eq!(decrypted.username.as_deref(), Some("alee"));
// 6. Wrong passphrase must fail
let wrong_key = derive_master_key(b"wrong", &extracted_secret, &salt, ¶ms).unwrap();
assert!(decrypt_entry(&wrong_key, &encrypted).is_err());
// 7. Wrong image_secret must fail
let wrong_secret = [0xFFu8; 32];
let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, ¶ms).unwrap();
assert!(decrypt_entry(&wrong_key2, &encrypted).is_err());
// 8. Manifest round-trip
let mut manifest = Manifest::new();
manifest.add_entry(
"abc123".into(),
ManifestEntry {
name: "GitHub".into(),
url: Some("https://github.com".into()),
username: Some("alee".into()),
updated_at: "2026-04-11T00:00:00Z".into(),
},
);
let enc_manifest = encrypt_manifest(&master_key, &manifest).unwrap();
let dec_manifest = decrypt_manifest(&master_key, &enc_manifest).unwrap();
assert_eq!(dec_manifest.entries.len(), 1);
assert!(dec_manifest.entries.contains_key("abc123"));
}
#[test]
fn two_factor_independence() {
// Verifies that changing EITHER factor produces a different key
let carrier = make_test_jpeg(400, 300);
let image_secret = [0xAAu8; 32];
let salt = [0x01u8; 32];
let params = KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
};
let key_original = derive_master_key(b"passphrase", &image_secret, &salt, ¶ms).unwrap();
// Different passphrase, same image → different key
let key_diff_pass = derive_master_key(b"other", &image_secret, &salt, ¶ms).unwrap();
assert_ne!(key_original, key_diff_pass);
// Same passphrase, different image → different key
let key_diff_img = derive_master_key(b"passphrase", &[0xBBu8; 32], &salt, ¶ms).unwrap();
assert_ne!(key_original, key_diff_img);
// Both different → different key
let key_both = derive_master_key(b"other", &[0xBBu8; 32], &salt, ¶ms).unwrap();
assert_ne!(key_original, key_both);
assert_ne!(key_diff_pass, key_both);
assert_ne!(key_diff_img, key_both);
}
- Step 2: Run integration tests
Run: cargo test -p relicario-core --test integration
Expected: Both tests PASS.
- Step 3: Run the full test suite
Run: cargo test
Expected: ALL tests across all crates PASS.
- Step 4: Commit
git add crates/relicario-core/tests/
git commit -m "test: add full-workflow integration test and two-factor independence verification"
Plan 2 Preview
After this plan is complete and passing, Plan 2 covers:
- relicario-wasm: wasm-bindgen wrapper around relicario-core (compile with
wasm-pack build) - Chrome MV3 extension: TypeScript popup + content script + service worker, loading the WASM module for inline crypto
- Extension UX: passphrase prompt, entry list/search, autofill detection
Plan 2 will be written as a separate plan document once Plan 1 is fully working.