Compare commits
70 Commits
519a6f0e36
...
plan-1b-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65e0d3cb80 | ||
|
|
c3edf9d413 | ||
|
|
20350d509b | ||
|
|
b263c27da9 | ||
|
|
494eedbbb8 | ||
|
|
b8afec3560 | ||
|
|
92b9e64ef9 | ||
|
|
fac2e49cf1 | ||
|
|
f3ce76d9fb | ||
|
|
8c315654ae | ||
|
|
a3871ac890 | ||
|
|
10f249d95e | ||
|
|
a6bad4bb3e | ||
|
|
cbd1dbd706 | ||
|
|
b5015b3e9b | ||
|
|
cc279bac0b | ||
|
|
06c8903e2b | ||
|
|
377d73355b | ||
|
|
ed451041b0 | ||
|
|
fe017455d3 | ||
|
|
89b22cb089 | ||
|
|
5dce2c10f9 | ||
|
|
a50099a066 | ||
|
|
15e6ed9c75 | ||
|
|
589d7b90b4 | ||
|
|
06d21bf7c9 | ||
|
|
6890926e31 | ||
|
|
c8535e11f5 | ||
|
|
7853db061e | ||
|
|
3e0cafb269 | ||
|
|
17bf47611f | ||
|
|
9c49e5e148 | ||
|
|
49b78203f8 | ||
|
|
3cf09faf1e | ||
|
|
557fb95b69 | ||
|
|
9cd5924109 | ||
|
|
08b1735b0e | ||
|
|
c7064183d6 | ||
|
|
950ae3d8dd | ||
|
|
2074677278 | ||
|
|
4a98be0dae | ||
|
|
f673b1ddee | ||
|
|
1fb0f8cc03 | ||
|
|
61b1a9710b | ||
|
|
61d6fb723d | ||
|
|
db3f2e15f2 | ||
|
|
b2d8a759ef | ||
|
|
266761232d | ||
|
|
1a30c4ffe0 | ||
|
|
a5ddbf2e40 | ||
|
|
509db707e0 | ||
|
|
23f7cb76b1 | ||
|
|
a95f92fe71 | ||
|
|
91b4b5b7a4 | ||
|
|
5786d9ef1a | ||
|
|
0b0f1cea73 | ||
|
|
0707628d58 | ||
|
|
316036832c | ||
|
|
ee25ffed41 | ||
|
|
24ed740718 | ||
|
|
bc60f0a6b4 | ||
|
|
0eac9c7991 | ||
|
|
87ead533e5 | ||
|
|
2ea7658036 | ||
|
|
1bd86bdb13 | ||
|
|
1e8ffb02a3 | ||
|
|
6c601fae08 | ||
|
|
69c2c7453b | ||
|
|
9a5ae2c704 | ||
|
|
166f1418f7 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,7 +1 @@
|
||||
target/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
extension/node_modules/
|
||||
extension/dist/
|
||||
extension/dist-firefox/
|
||||
extension/wasm/
|
||||
ref.jpg
|
||||
|
||||
1
.relicario/devices.json
Normal file
1
.relicario/devices.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
11
.relicario/params.json
Normal file
11
.relicario/params.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"kdf": {
|
||||
"algorithm": "argon2id-v0x13",
|
||||
"argon2_m": 65536,
|
||||
"argon2_t": 3,
|
||||
"argon2_p": 4
|
||||
},
|
||||
"aead": "xchacha20poly1305",
|
||||
"salt_path": ".relicario/salt"
|
||||
}
|
||||
1
.relicario/salt
Normal file
1
.relicario/salt
Normal file
@@ -0,0 +1 @@
|
||||
ο½48^ΖΥ<CE96>\Χ<>ο<EFBFBD>Ιl$$Η.ώφrΥΩΛ
|
||||
51
CLAUDE.md
51
CLAUDE.md
@@ -7,30 +7,43 @@ relicario is a git-backed, self-hostable password manager with a Rust core. Two-
|
||||
## Build and test
|
||||
|
||||
```bash
|
||||
cargo build # build everything
|
||||
cargo test # run all tests (unit + integration)
|
||||
cargo test -p relicario-core # core library tests only
|
||||
cargo run -- --help # CLI help
|
||||
cargo run -- generate -l 32 # quick smoke test
|
||||
cargo build # build everything
|
||||
cargo test # run all tests (unit + integration)
|
||||
cargo test -p relicario-core # core library tests only
|
||||
cargo test -p relicario-cli --test basic_flows # CLI integration tests
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown # WASM target
|
||||
cargo run -p relicario-cli -- --help # CLI help
|
||||
cargo run -p relicario-cli -- generate --length 32 # quick smoke test
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
crates/
|
||||
├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network)
|
||||
├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network)
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs # Re-exports public API
|
||||
│ │ ├── error.rs # RelicarioError enum (thiserror)
|
||||
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 encrypt/decrypt
|
||||
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs (serde)
|
||||
│ │ ├── vault.rs # encrypt_entry, decrypt_entry, encrypt_manifest, decrypt_manifest
|
||||
│ │ └── imgsecret.rs # DCT-based 256-bit secret embedding in JPEGs
|
||||
│ └── tests/
|
||||
│ └── integration.rs # Full-workflow and two-factor independence tests
|
||||
└── relicario-cli/ # CLI binary
|
||||
└── src/
|
||||
└── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device
|
||||
│ │ ├── lib.rs # Re-exports public API
|
||||
│ │ ├── error.rs # RelicarioError enum (thiserror)
|
||||
│ │ ├── crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305
|
||||
│ │ ├── ids.rs # ItemId, FieldId, content-addressed AttachmentId
|
||||
│ │ ├── time.rs # now_unix, MonthYear
|
||||
│ │ ├── item_types/ # per-type cores + ItemType/ItemCore enums
|
||||
│ │ ├── item.rs # Item envelope, Field, FieldKind, FieldValue, Section
|
||||
│ │ ├── attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers
|
||||
│ │ ├── manifest.rs # Browse-without-decrypt index (schema_version 2)
|
||||
│ │ ├── settings.rs # VaultSettings: retention, generator defaults, caps
|
||||
│ │ ├── generators.rs # CSPRNG password + BIP39 + zxcvbn gate
|
||||
│ │ ├── vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings
|
||||
│ │ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap)
|
||||
│ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs
|
||||
├── relicario-cli/ # `relicario` binary
|
||||
│ ├── src/main.rs # clap surface + command handlers
|
||||
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||
└── relicario-wasm/ # WASM bindings for the extension
|
||||
├── src/lib.rs # #[wasm_bindgen] surface
|
||||
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
@@ -49,14 +62,14 @@ 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
|
||||
→ encrypted Item/Manifest/VaultSettings
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
|
||||
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
|
||||
- Entry IDs are random 8-char hex strings.
|
||||
- Item IDs are random 8-char hex strings.
|
||||
- Git history is preserved as an audit log — no squashing.
|
||||
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
||||
|
||||
|
||||
1091
Cargo.lock
generated
1091
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,22 @@ path = "src/main.rs"
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
rpassword = "5"
|
||||
rpassword = "7"
|
||||
arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
dirs = "5"
|
||||
hex = "0.4"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
zeroize = "1"
|
||||
url = "2"
|
||||
data-encoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
serde_json = "1"
|
||||
|
||||
101
crates/relicario-cli/src/helpers.rs
Normal file
101
crates/relicario-cli/src/helpers.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
|
||||
//! timestamp formatting. Kept in their own module so every command handler
|
||||
//! stays terse.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::DateTime;
|
||||
|
||||
/// Walk up from `start` looking for a directory containing `.relicario/`.
|
||||
/// Returns the vault root (the directory that contains `.relicario/`).
|
||||
/// Audit L8: refuses to operate outside an initialized vault.
|
||||
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
|
||||
let mut cur = start.to_path_buf();
|
||||
loop {
|
||||
if cur.join(".relicario").is_dir() {
|
||||
return Ok(cur);
|
||||
}
|
||||
if !cur.pop() {
|
||||
bail!(
|
||||
"no .relicario/ directory found in {} or any parent — \
|
||||
run `relicario init` first",
|
||||
start.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper that starts the search from `std::env::current_dir()`.
|
||||
pub fn vault_dir() -> Result<PathBuf> {
|
||||
let cwd = std::env::current_dir().context("failed to get current directory")?;
|
||||
find_vault_dir_from(&cwd)
|
||||
}
|
||||
|
||||
/// Path to the `.relicario/` configuration directory within the vault.
|
||||
pub fn relicario_dir() -> Result<PathBuf> {
|
||||
Ok(vault_dir()?.join(".relicario"))
|
||||
}
|
||||
|
||||
/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
|
||||
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
|
||||
/// GPG passphrase prompts (which would hold the master key alive), or entering
|
||||
/// $EDITOR during rebase conflict markers.
|
||||
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(repo);
|
||||
cmd.args([
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"-c", "core.editor=true",
|
||||
]);
|
||||
cmd.args(args);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||
/// a numeric string.
|
||||
pub fn iso8601(unix_seconds: i64) -> String {
|
||||
DateTime::from_timestamp(unix_seconds, 0)
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn vault_dir_finds_marker_in_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||||
let found = find_vault_dir_from(tmp.path()).unwrap();
|
||||
assert_eq!(found, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_dir_finds_marker_in_parent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||||
let subdir = tmp.path().join("sub/nested");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
let found = find_vault_dir_from(&subdir).unwrap();
|
||||
assert_eq!(found, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_dir_errors_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = find_vault_dir_from(tmp.path()).unwrap_err();
|
||||
assert!(err.to_string().contains(".relicario"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_formats_fixed_timestamp() {
|
||||
// 2026-04-19T00:00:00Z = 1776556800
|
||||
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
151
crates/relicario-cli/src/session.rs
Normal file
151
crates/relicario-cli/src/session.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Unlocked-vault session: the shape every vault-mutating command works with.
|
||||
//!
|
||||
//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a
|
||||
//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::{
|
||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||
derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings,
|
||||
imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings,
|
||||
};
|
||||
|
||||
use crate::helpers::vault_dir;
|
||||
|
||||
/// A vault whose master key has been derived and is held in memory.
|
||||
/// The key is wiped via `Zeroize` when this struct drops.
|
||||
pub struct UnlockedVault {
|
||||
root: PathBuf,
|
||||
master_key: Zeroizing<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl UnlockedVault {
|
||||
pub fn root(&self) -> &Path { &self.root }
|
||||
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key }
|
||||
|
||||
/// Full interactive unlock flow: locate vault, prompt passphrase, locate
|
||||
/// reference image, derive master key.
|
||||
pub fn unlock_interactive() -> Result<Self> {
|
||||
let root = vault_dir()?;
|
||||
let salt = read_salt(&root)?;
|
||||
let params = read_params(&root)?;
|
||||
let image_path = get_image_path()?;
|
||||
let image_bytes = fs::read(&image_path)
|
||||
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
||||
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
||||
|
||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(
|
||||
rpassword::prompt_password("Passphrase: ")
|
||||
.context("failed to read passphrase")?
|
||||
)
|
||||
};
|
||||
|
||||
let master_key = derive_master_key(
|
||||
passphrase.as_bytes(),
|
||||
&*image_secret,
|
||||
&salt,
|
||||
¶ms,
|
||||
)?;
|
||||
|
||||
Ok(Self { root, master_key })
|
||||
}
|
||||
|
||||
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
|
||||
pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") }
|
||||
pub fn item_path(&self, id: &ItemId) -> PathBuf {
|
||||
self.root.join("items").join(format!("{}.enc", id.as_str()))
|
||||
}
|
||||
|
||||
pub fn load_manifest(&self) -> Result<Manifest> {
|
||||
let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?;
|
||||
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
|
||||
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||
atomic_write(&self.manifest_path(), &bytes)
|
||||
}
|
||||
|
||||
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||
let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?;
|
||||
Ok(decrypt_settings(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> {
|
||||
let bytes = encrypt_settings(settings, &self.master_key)?;
|
||||
atomic_write(&self.settings_path(), &bytes)
|
||||
}
|
||||
|
||||
pub fn load_item(&self, id: &ItemId) -> Result<Item> {
|
||||
let bytes = fs::read(self.item_path(id))
|
||||
.with_context(|| format!("failed to read item {}", id.as_str()))?;
|
||||
Ok(decrypt_item(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_item(&self, item: &Item) -> Result<()> {
|
||||
let path = self.item_path(&item.id);
|
||||
if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
|
||||
let bytes = encrypt_item(item, &self.master_key)?;
|
||||
atomic_write(&path, &bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
||||
let data = fs::read(root.join(".relicario").join("salt"))
|
||||
.context("failed to read .relicario/salt")?;
|
||||
if data.len() != 32 { bail!("invalid salt length: {}", data.len()); }
|
||||
let mut salt = [0u8; 32];
|
||||
salt.copy_from_slice(&data);
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ParamsFile {
|
||||
kdf: KdfParams,
|
||||
}
|
||||
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||
.context("failed to read .relicario/params.json")?;
|
||||
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||
Ok(pf.kdf)
|
||||
}
|
||||
|
||||
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||
pub fn get_image_path() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
// Also accept <vault_root>/reference.jpg as a convention.
|
||||
if let Ok(root) = vault_dir() {
|
||||
let default = root.join("reference.jpg");
|
||||
if default.exists() { return Ok(default); }
|
||||
}
|
||||
eprint!("Reference image path: ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() { bail!("no reference image path provided"); }
|
||||
Ok(PathBuf::from(trimmed))
|
||||
}
|
||||
|
||||
/// Atomic write: write to <path>.tmp, then rename over <path>. Keeps the
|
||||
/// vault file consistent if we crash mid-write.
|
||||
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
let mut tmp = path.as_os_str().to_owned();
|
||||
tmp.push(".tmp");
|
||||
let tmp = PathBuf::from(tmp);
|
||||
fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
45
crates/relicario-cli/tests/attachments.rs
Normal file
45
crates/relicario-cli/tests/attachments.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
mod common;
|
||||
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn attach_list_extract_round_trip() {
|
||||
let v = TestVault::init();
|
||||
v.run(&["add", "login", "--title", "thing",
|
||||
"--username", "u", "--password", "p"]);
|
||||
|
||||
let payload_path = v.path().join("payload.txt");
|
||||
std::fs::write(&payload_path, b"attached-bytes").unwrap();
|
||||
|
||||
let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
|
||||
assert!(attach.status.success(), "attach failed: {:?}", attach);
|
||||
|
||||
let list = v.run(&["attachments", "thing"]);
|
||||
let stdout = String::from_utf8(list.stdout).unwrap();
|
||||
assert!(stdout.contains("payload.txt"), "missing payload: {stdout}");
|
||||
|
||||
let aid = stdout.lines()
|
||||
.find(|l| l.contains("payload.txt"))
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.expect("aid token");
|
||||
|
||||
let out_path = v.path().join("extracted.txt");
|
||||
let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]);
|
||||
assert!(ex.status.success(), "extract failed: {:?}", ex);
|
||||
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_rejects_over_cap() {
|
||||
let v = TestVault::init();
|
||||
v.run(&["add", "login", "--title", "thing",
|
||||
"--username", "u", "--password", "p"]);
|
||||
|
||||
v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]);
|
||||
|
||||
let big = v.path().join("big.bin");
|
||||
std::fs::write(&big, vec![0u8; 100]).unwrap();
|
||||
let out = v.run(&["attach", "thing", big.to_str().unwrap()]);
|
||||
assert!(!out.status.success(), "expected failure; got {:?}", out);
|
||||
assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment"));
|
||||
}
|
||||
136
crates/relicario-cli/tests/basic_flows.rs
Normal file
136
crates/relicario-cli/tests/basic_flows.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
mod common;
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn init_creates_expected_layout() {
|
||||
let v = TestVault::init();
|
||||
assert!(v.path().join(".relicario/salt").exists());
|
||||
assert!(v.path().join(".relicario/params.json").exists());
|
||||
assert!(v.path().join(".relicario/devices.json").exists());
|
||||
assert!(v.path().join("manifest.enc").exists());
|
||||
assert!(v.path().join("settings.enc").exists());
|
||||
assert!(v.path().join("reference.jpg").exists());
|
||||
assert!(v.path().join(".gitignore").exists());
|
||||
assert!(v.path().join(".git").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_params_json_is_format_v2() {
|
||||
let v = TestVault::init();
|
||||
let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed["format_version"], 2);
|
||||
assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13");
|
||||
assert_eq!(parsed["aead"], "xchacha20poly1305");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_login_then_list_shows_it() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run(&[
|
||||
"add",
|
||||
"login",
|
||||
"--title",
|
||||
"GitHub",
|
||||
"--username",
|
||||
"alice",
|
||||
"--url",
|
||||
"https://github.com",
|
||||
"--password",
|
||||
"hunter2",
|
||||
]);
|
||||
assert!(out.status.success(), "add failed: {:?}", out);
|
||||
let out = v.run(&["list"]);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_masks_by_default_shows_with_flag() {
|
||||
let v = TestVault::init();
|
||||
v.run(&[
|
||||
"add",
|
||||
"login",
|
||||
"--title",
|
||||
"gmail",
|
||||
"--username",
|
||||
"u",
|
||||
"--password",
|
||||
"super-secret",
|
||||
]);
|
||||
|
||||
let masked = v.run(&["get", "gmail"]);
|
||||
let stdout = String::from_utf8(masked.stdout).unwrap();
|
||||
assert!(stdout.contains("********"), "expected masked: {stdout}");
|
||||
assert!(
|
||||
!stdout.contains("super-secret"),
|
||||
"leaked plaintext: {stdout}"
|
||||
);
|
||||
|
||||
let shown = v.run(&["get", "gmail", "--show"]);
|
||||
let stdout = String::from_utf8(shown.stdout).unwrap();
|
||||
assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rm_restore_purge_cycle() {
|
||||
let v = TestVault::init();
|
||||
v.run(&[
|
||||
"add",
|
||||
"login",
|
||||
"--title",
|
||||
"target",
|
||||
"--username",
|
||||
"u",
|
||||
"--password",
|
||||
"p",
|
||||
]);
|
||||
|
||||
let rm = v.run(&["rm", "target"]);
|
||||
assert!(rm.status.success());
|
||||
|
||||
let out = v.run(&["list"]);
|
||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
|
||||
let out = v.run(&["list", "--trashed"]);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
|
||||
let restore = v.run(&["restore", "target"]);
|
||||
assert!(restore.status.success());
|
||||
let out = v.run(&["list"]);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
|
||||
let purge = v.run(&["purge", "target"]);
|
||||
assert!(purge.status.success());
|
||||
let out = v.run(&["list"]);
|
||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_and_bip39() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let out = std::process::Command::cargo_bin("relicario")
|
||||
.unwrap()
|
||||
.current_dir(dir.path())
|
||||
.args(["generate", "--length", "32"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
assert_eq!(
|
||||
String::from_utf8(out.stdout).unwrap().trim().len(),
|
||||
32
|
||||
);
|
||||
|
||||
let out = std::process::Command::cargo_bin("relicario")
|
||||
.unwrap()
|
||||
.current_dir(dir.path())
|
||||
.args(["generate", "--bip39", "--words", "5"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
let phrase = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(phrase.trim().split(' ').count(), 5);
|
||||
}
|
||||
117
crates/relicario-cli/tests/common/mod.rs
Normal file
117
crates/relicario-cli/tests/common/mod.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Shared helpers for CLI integration tests.
|
||||
//!
|
||||
//! `TestVault::init()` spins up a fresh vault in a `TempDir` using
|
||||
//! `RELICARIO_TEST_PASSPHRASE` as the escape hatch (bypasses TTY prompts).
|
||||
//! Every `run()` / `run_with_input()` call sets both `RELICARIO_IMAGE` and
|
||||
//! `RELICARIO_TEST_PASSPHRASE`, so vault-mutating commands unlock without
|
||||
//! interactive input.
|
||||
//!
|
||||
//! Note for Task 23 implementers: commands that prompt for a *new item
|
||||
//! password* (i.e. `edit` when changing a Login password) also use
|
||||
//! `rpassword`. Plumb `RELICARIO_TEST_ITEM_PASSWORD` through `cmd_edit` in
|
||||
//! main.rs, or use an item type / edit path that avoids the rpassword call.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub struct TestVault {
|
||||
pub dir: TempDir,
|
||||
pub reference_image: PathBuf,
|
||||
pub passphrase: String,
|
||||
}
|
||||
|
||||
impl TestVault {
|
||||
pub fn init() -> Self {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let carrier = make_test_jpeg(400, 300);
|
||||
let carrier_path = dir.path().join("carrier.jpg");
|
||||
std::fs::write(&carrier_path, &carrier).unwrap();
|
||||
|
||||
let passphrase = "correct horse battery staple 2026".to_string();
|
||||
let ref_path = dir.path().join("reference.jpg");
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(dir.path())
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &passphrase)
|
||||
.args([
|
||||
"init",
|
||||
"--image",
|
||||
carrier_path.to_str().unwrap(),
|
||||
"--output",
|
||||
ref_path.to_str().unwrap(),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"init failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
Self {
|
||||
dir,
|
||||
reference_image: ref_path,
|
||||
passphrase,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
self.dir.path()
|
||||
}
|
||||
|
||||
pub fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
.env("RELICARIO_IMAGE", &self.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
.env("RELICARIO_IMAGE", &self.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
for line in extra {
|
||||
writeln!(stdin, "{line}").unwrap();
|
||||
}
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_test_jpeg(w: u32, h: u32) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb};
|
||||
|
||||
let img = ImageBuffer::from_fn(w, h, |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 out = Vec::new();
|
||||
JpegEncoder::new_with_quality(&mut out, 92)
|
||||
.write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
59
crates/relicario-cli/tests/edit_and_history.rs
Normal file
59
crates/relicario-cli/tests/edit_and_history.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
mod common;
|
||||
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn edit_password_captures_history() {
|
||||
let v = TestVault::init();
|
||||
v.run(&["add", "login", "--title", "bank",
|
||||
"--username", "u", "--password", "first-pw"]);
|
||||
|
||||
// edit: accept defaults on title/group/tags/username/url, then change pw.
|
||||
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||
assert!(out.status.success(), "edit failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Verify the edit commit exists in git log.
|
||||
let log = std::process::Command::new("git")
|
||||
.current_dir(v.path()).args(["log", "--oneline"])
|
||||
.output().unwrap();
|
||||
let log_str = String::from_utf8(log.stdout).unwrap();
|
||||
assert!(log_str.contains("edit: bank"), "missing edit commit: {log_str}");
|
||||
|
||||
// And the item file has been re-written (there's a single items/<id>.enc).
|
||||
let items_dir = v.path().join("items");
|
||||
let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap()
|
||||
.map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
|
||||
/// Drives the interactive `edit` flow end-to-end:
|
||||
/// 1. passphrase via env var.
|
||||
/// 2. blank lines for title, group, tags, username, url.
|
||||
/// 3. "y" for "Change password?"
|
||||
/// 4. new password via RELICARIO_TEST_ITEM_SECRET env var.
|
||||
fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::process::Output {
|
||||
use assert_cmd::cargo::CommandCargoExt;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(v.path())
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.env("RELICARIO_TEST_ITEM_SECRET", new_pw)
|
||||
.args(["edit", query])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
// title, group, tags, username, url (keep defaults), then yes-to-change-pw.
|
||||
for line in ["", "", "", "", "", "y"] {
|
||||
writeln!(stdin, "{line}").unwrap();
|
||||
}
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
23
crates/relicario-cli/tests/settings.rs
Normal file
23
crates/relicario-cli/tests/settings.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
mod common;
|
||||
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn settings_roundtrip_trash_retention() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run(&["settings", "show"]);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention"));
|
||||
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "60"]);
|
||||
assert!(out.status.success(), "set failed: {:?}", out);
|
||||
let out = v.run(&["settings", "show"]);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(stdout.contains("60"), "expected 60: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_rejects_conflicting_retention_flags() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
59
crates/relicario-cli/tests/vault_detection.rs
Normal file
59
crates/relicario-cli/tests/vault_detection.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
mod common;
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn list_refuses_without_vault_marker() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// No .relicario/ in dir — list should bail with a friendly error.
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
let out = cmd.current_dir(dir.path())
|
||||
.env("RELICARIO_TEST_PASSPHRASE", "foo")
|
||||
.arg("list")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_finds_vault_in_parent_dir() {
|
||||
let v = common::TestVault::init();
|
||||
v.run(&["add", "login", "--title", "parent-test",
|
||||
"--username", "u", "--password", "p"]);
|
||||
|
||||
// Create a nested subdir and run `list` from inside it.
|
||||
let nested = v.path().join("a/b/c");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(&nested)
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.arg("list");
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "list from nested dir failed: {:?}", out);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_vault_is_rejected_with_clear_error() {
|
||||
// Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json.
|
||||
// Since vault_dir detection uses .relicario/, the pre-rename dir name is
|
||||
// naturally rejected without any compat shim. Confirm that.
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join(".idfoto")).unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
let out = cmd.current_dir(dir.path())
|
||||
.env("RELICARIO_TEST_PASSPHRASE", "foo")
|
||||
.arg("list")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}");
|
||||
}
|
||||
@@ -12,7 +12,19 @@ argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
hmac = "0.12"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
# Typed-item additions
|
||||
zeroize = { version = "1", features = ["zeroize_derive", "serde"] }
|
||||
zxcvbn = { version = "3", default-features = false }
|
||||
bip39 = { version = "2", default-features = false, features = ["std"] }
|
||||
unicode-normalization = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] }
|
||||
hex = "0.4"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
getrandom = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
166
crates/relicario-core/src/attachment.rs
Normal file
166
crates/relicario-core/src/attachment.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Attachment refs (carried on Item) and summaries (carried in Manifest).
|
||||
//!
|
||||
//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added
|
||||
//! later in Task 22 once the crypto module is settled.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::AttachmentId;
|
||||
|
||||
/// Reference to an attachment, carried on the Item record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttachmentRef {
|
||||
pub id: AttachmentId,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
/// Plaintext size in bytes.
|
||||
pub size: u64,
|
||||
/// Unix-seconds when this attachment was added.
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
/// Compact summary of an attachment, carried in the Manifest so the popup
|
||||
/// can show attachment indicators without decrypting the item file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttachmentSummary {
|
||||
pub id: AttachmentId,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
impl From<&AttachmentRef> for AttachmentSummary {
|
||||
fn from(r: &AttachmentRef) -> Self {
|
||||
Self {
|
||||
id: r.id.clone(),
|
||||
filename: r.filename.clone(),
|
||||
mime_type: r.mime_type.clone(),
|
||||
size: r.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::crypto::{decrypt, encrypt};
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Encrypted attachment with the AID derived from plaintext content.
|
||||
#[derive(Debug)]
|
||||
pub struct EncryptedAttachment {
|
||||
pub id: AttachmentId,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`.
|
||||
///
|
||||
/// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`,
|
||||
/// before any crypto work is done.
|
||||
///
|
||||
/// ## Call-site adaptation
|
||||
///
|
||||
/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via
|
||||
/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`).
|
||||
pub fn encrypt_attachment(
|
||||
plaintext: &[u8],
|
||||
master_key: &Zeroizing<[u8; 32]>,
|
||||
max_bytes: u64,
|
||||
) -> Result<EncryptedAttachment> {
|
||||
if plaintext.len() as u64 > max_bytes {
|
||||
return Err(RelicarioError::AttachmentTooLarge {
|
||||
size: plaintext.len() as u64,
|
||||
max: max_bytes,
|
||||
});
|
||||
}
|
||||
let id = AttachmentId::from_plaintext(plaintext);
|
||||
let bytes = encrypt(master_key, plaintext)?;
|
||||
Ok(EncryptedAttachment { id, bytes })
|
||||
}
|
||||
|
||||
/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext
|
||||
/// wrapped in [`Zeroizing`] so it is wiped on drop.
|
||||
///
|
||||
/// ## Call-site adaptation
|
||||
///
|
||||
/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`.
|
||||
pub fn decrypt_attachment(
|
||||
encrypted: &[u8],
|
||||
master_key: &Zeroizing<[u8; 32]>,
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
Ok(Zeroizing::new(plaintext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crypto_tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> Zeroizing<[u8; 32]> {
|
||||
Zeroizing::new([0x42u8; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_round_trip() {
|
||||
let plaintext = b"the quick brown fox jumps over the lazy dog";
|
||||
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||
let dec = decrypt_attachment(&enc.bytes, &key()).unwrap();
|
||||
assert_eq!(dec.as_slice(), plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_matches_sha256() {
|
||||
let plaintext = b"hello world";
|
||||
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||
assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_attachment_rejected() {
|
||||
let plaintext = vec![0u8; 11_000_000];
|
||||
let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024);
|
||||
assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_with_opaque_decrypt() {
|
||||
let plaintext = b"x";
|
||||
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||
let wrong = Zeroizing::new([0u8; 32]);
|
||||
let err = decrypt_attachment(&enc.bytes, &wrong);
|
||||
assert!(matches!(err, Err(RelicarioError::Decrypt)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attachment_ref_round_trip() {
|
||||
let r = AttachmentRef {
|
||||
id: AttachmentId("0123456789abcdef".into()),
|
||||
filename: "doc.pdf".into(),
|
||||
mime_type: "application/pdf".into(),
|
||||
size: 12345,
|
||||
created: 1_700_000_000,
|
||||
};
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
let parsed: AttachmentRef = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.filename, "doc.pdf");
|
||||
assert_eq!(parsed.size, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_summary_from_ref() {
|
||||
let r = AttachmentRef {
|
||||
id: AttachmentId("aabb".into()),
|
||||
filename: "x.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size: 5,
|
||||
created: 0,
|
||||
};
|
||||
let s: AttachmentSummary = (&r).into();
|
||||
assert_eq!(s.filename, "x.txt");
|
||||
assert_eq!(s.id, r.id);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
|
||||
//! ```
|
||||
//!
|
||||
//! - **Version byte** (`0x01`): allows future format changes without ambiguity.
|
||||
//! - **Version byte** (`0x02`): allows future format changes without ambiguity.
|
||||
//! Decryption rejects any version it does not recognize.
|
||||
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
|
||||
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
|
||||
@@ -50,11 +50,13 @@ use chacha20poly1305::{
|
||||
};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Current binary format version. Increment this if the ciphertext layout changes.
|
||||
const VERSION_BYTE: u8 = 0x01;
|
||||
pub const VERSION_BYTE: u8 = 0x02;
|
||||
|
||||
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
|
||||
const NONCE_LEN: usize = 24;
|
||||
@@ -121,12 +123,12 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
));
|
||||
}
|
||||
|
||||
let version = data[0];
|
||||
if version != VERSION_BYTE {
|
||||
return Err(RelicarioError::Format(format!(
|
||||
"unknown version byte: 0x{:02x}",
|
||||
version
|
||||
)));
|
||||
let found = data[0];
|
||||
if found != VERSION_BYTE {
|
||||
return Err(RelicarioError::UnsupportedFormatVersion {
|
||||
found,
|
||||
expected: VERSION_BYTE,
|
||||
});
|
||||
}
|
||||
|
||||
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
|
||||
@@ -207,7 +209,7 @@ pub fn derive_master_key(
|
||||
image_secret: &[u8; 32],
|
||||
salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<[u8; 32]> {
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let argon2_params = Params::new(
|
||||
params.argon2_m,
|
||||
params.argon2_t,
|
||||
@@ -218,17 +220,24 @@ pub fn derive_master_key(
|
||||
|
||||
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);
|
||||
// Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
|
||||
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
|
||||
Ok(s) => s.nfc().collect::<String>().into_bytes(),
|
||||
Err(_) => passphrase.to_vec(),
|
||||
};
|
||||
|
||||
// Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
|
||||
// [u64_be(32)][image_secret]
|
||||
// Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
|
||||
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
|
||||
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
|
||||
password.extend_from_slice(&nfc_passphrase);
|
||||
password.extend_from_slice(&32u64.to_be_bytes());
|
||||
password.extend_from_slice(image_secret);
|
||||
|
||||
let mut output = [0u8; 32];
|
||||
let mut output = Zeroizing::new([0u8; 32]);
|
||||
argon2
|
||||
.hash_password_into(&password, salt, &mut output)
|
||||
.hash_password_into(password.as_slice(), salt, output.as_mut())
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
|
||||
Ok(output)
|
||||
@@ -256,7 +265,7 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(*key1, *key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,7 +277,7 @@ mod tests {
|
||||
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);
|
||||
assert_ne!(*key1, *key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -283,7 +292,7 @@ mod tests {
|
||||
let key1 = derive_master_key(passphrase, &image_secret1, &salt, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(passphrase, &image_secret2, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_ne!(key1, key2);
|
||||
assert_ne!(*key1, *key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -335,7 +344,77 @@ mod tests {
|
||||
let expected_len = 1 + 24 + plaintext.len() + 16;
|
||||
assert_eq!(ciphertext.len(), expected_len);
|
||||
|
||||
// Version byte must be 0x01
|
||||
assert_eq!(ciphertext[0], 0x01);
|
||||
// Version byte must be 0x02
|
||||
assert_eq!(ciphertext[0], 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefix_eliminates_concatenation_ambiguity() {
|
||||
// Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide.
|
||||
// With length-prefix: distinct inputs always yield distinct keys.
|
||||
let salt = [0u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
// Pair A: passphrase "abc", image_secret starts with 0x44
|
||||
let mut img_a = [0u8; 32]; img_a[0] = 0x44;
|
||||
let key_a = derive_master_key(b"abc", &img_a, &salt, ¶ms).unwrap();
|
||||
|
||||
// Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1
|
||||
let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image
|
||||
let key_b = derive_master_key(b"abcD", &img_b, &salt, ¶ms).unwrap();
|
||||
|
||||
// With length-prefix, the keys MUST differ.
|
||||
assert_ne!(*key_a, *key_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nfc_normalization_collapses_unicode_forms() {
|
||||
// "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301).
|
||||
// Both must produce the same key after NFC normalization.
|
||||
let salt = [0u8; 32];
|
||||
let img = [0u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
let nfc = "caf\u{00e9}".as_bytes(); // é precomposed
|
||||
let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute
|
||||
|
||||
let key_nfc = derive_master_key(nfc, &img, &salt, ¶ms).unwrap();
|
||||
let key_nfd = derive_master_key(nfd, &img, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(*key_nfc, *key_nfd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_key_is_zeroized_on_drop() {
|
||||
// Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if
|
||||
// we wrap correctly. The drop wipe is verified by the zeroize crate's tests.
|
||||
let salt = [0u8; 32];
|
||||
let img = [0u8; 32];
|
||||
let params = fast_params();
|
||||
let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, ¶ms).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_byte_is_0x02() {
|
||||
assert_eq!(VERSION_BYTE, 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_v1_blob_with_typed_error() {
|
||||
// Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes].
|
||||
let mut blob = vec![0x01u8];
|
||||
blob.extend_from_slice(&[0u8; 24]);
|
||||
blob.extend_from_slice(&[0u8; 16]);
|
||||
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
|
||||
match err {
|
||||
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||
assert_eq!(found, 0x01);
|
||||
assert_eq!(expected, 0x02);
|
||||
}
|
||||
other => panic!("expected UnsupportedFormatVersion, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
//! Vault data model: entries, manifest entries, and the manifest index.
|
||||
//!
|
||||
//! The vault stores credentials in two tiers:
|
||||
//!
|
||||
//! 1. **Individual entries** (`entries/<id>.enc`): each file contains a single
|
||||
//! [`Entry`] encrypted with the master key. Only decrypted when the user
|
||||
//! needs to read or edit a specific credential.
|
||||
//!
|
||||
//! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a
|
||||
//! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries.
|
||||
//! This lets the CLI list and search entries by decrypting only one file,
|
||||
//! rather than decrypting every entry in the vault.
|
||||
//!
|
||||
//! ## Entry IDs
|
||||
//!
|
||||
//! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy,
|
||||
//! ~4 billion possible values). This is sufficient for family-scale vaults while
|
||||
//! keeping filenames short and filesystem-friendly.
|
||||
//!
|
||||
//! ## Serialization strategy
|
||||
//!
|
||||
//! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields
|
||||
//! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact
|
||||
//! -- omitting null fields reduces ciphertext size and avoids leaking structural
|
||||
//! information about which optional fields a credential uses.
|
||||
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A full credential entry stored encrypted in `entries/<id>.enc`.
|
||||
///
|
||||
/// Contains all sensitive data for a single credential. Each entry is encrypted
|
||||
/// independently, so accessing one entry does not require decrypting others.
|
||||
///
|
||||
/// ## Fields
|
||||
///
|
||||
/// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required.
|
||||
/// - `url`: the login URL. Optional; used for autofill matching in the browser extension.
|
||||
/// - `username`: the account username or email. Optional.
|
||||
/// - `password`: the credential password. Required (this is the core secret).
|
||||
/// - `notes`: free-form text (e.g., security questions, recovery codes). Optional.
|
||||
/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional.
|
||||
/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created.
|
||||
/// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification.
|
||||
/// - `group`: optional group label for organizing entries (e.g. "work", "personal").
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub totp_secret: Option<String>,
|
||||
/// Optional group for organizing entries (e.g. "work", "personal").
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Summary metadata for a single entry, stored in the manifest.
|
||||
///
|
||||
/// This is a lightweight projection of [`Entry`] that contains only the
|
||||
/// non-sensitive fields needed for listing and searching. The password,
|
||||
/// notes, and TOTP secret are intentionally excluded so that listing
|
||||
/// entries requires decrypting only the manifest, not every individual entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestEntry {
|
||||
/// Human-readable label for display and search matching.
|
||||
pub name: String,
|
||||
/// Login URL for search matching and browser extension autofill.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// Account username for display in entry listings.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
/// Optional group for organizing entries (e.g. "work", "personal").
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
/// Timestamp of last modification, used for sorting and display.
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// The vault manifest -- an encrypted index mapping entry IDs to their metadata.
|
||||
///
|
||||
/// The manifest serves two purposes:
|
||||
///
|
||||
/// 1. **Efficient listing**: decrypting the single manifest file is enough to show
|
||||
/// all entry names, URLs, and usernames without touching individual entry files.
|
||||
/// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive
|
||||
/// substring matching against entry names and URLs.
|
||||
///
|
||||
/// The `version` field allows future schema migrations if the manifest format evolves.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
/// Map from entry ID (8-char hex string) to entry metadata.
|
||||
pub entries: HashMap<String, ManifestEntry>,
|
||||
/// Schema version. Currently always `1`.
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Create a new empty manifest with version 1.
|
||||
pub fn new() -> Self {
|
||||
Manifest {
|
||||
entries: HashMap::new(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or update an entry in the manifest.
|
||||
///
|
||||
/// If an entry with the same ID already exists, it is overwritten.
|
||||
/// This is used both for `add` (new entry) and `edit` (update existing).
|
||||
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
|
||||
self.entries.insert(id, entry);
|
||||
}
|
||||
|
||||
/// Remove an entry from the manifest by ID, returning its metadata if it existed.
|
||||
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
|
||||
self.entries.remove(id)
|
||||
}
|
||||
|
||||
/// Search entries by case-insensitive substring match against name and URL.
|
||||
///
|
||||
/// Returns a vector of `(id, entry)` pairs for all matching entries. An entry
|
||||
/// matches if the query appears in its name or URL (case-insensitive).
|
||||
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
|
||||
let q = query.to_lowercase();
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
e.name.to_lowercase().contains(&q)
|
||||
|| e.url
|
||||
.as_deref()
|
||||
.map(|u| u.to_lowercase().contains(&q))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Manifest {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random 8-character hex string to use as an entry ID.
|
||||
///
|
||||
/// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`.
|
||||
/// This gives ~4 billion possible values, which is more than sufficient for
|
||||
/// a family-scale vault (typically < 1000 entries).
|
||||
pub fn generate_entry_id() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 4] = rng.gen();
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entry_serialization_round_trip() {
|
||||
let entry = Entry {
|
||||
name: "GitHub".to_string(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: Some("alice".to_string()),
|
||||
password: "s3cr3t".to_string(),
|
||||
notes: None,
|
||||
totp_secret: None,
|
||||
group: None,
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
let decoded: Entry = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.name, entry.name);
|
||||
assert_eq!(decoded.url, entry.url);
|
||||
assert_eq!(decoded.username, entry.username);
|
||||
assert_eq!(decoded.password, entry.password);
|
||||
assert_eq!(decoded.notes, entry.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_add_and_lookup() {
|
||||
let mut manifest = Manifest::new();
|
||||
let me = ManifestEntry {
|
||||
name: "GitHub".to_string(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: Some("alice".to_string()),
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
manifest.add_entry("abc12345".to_string(), me);
|
||||
|
||||
assert!(manifest.entries.contains_key("abc12345"));
|
||||
assert_eq!(manifest.entries["abc12345"].name, "GitHub");
|
||||
|
||||
let removed = manifest.remove_entry("abc12345");
|
||||
assert!(removed.is_some());
|
||||
assert!(!manifest.entries.contains_key("abc12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_serialization_round_trip() {
|
||||
let mut manifest = Manifest::new();
|
||||
manifest.add_entry(
|
||||
"deadbeef".to_string(),
|
||||
ManifestEntry {
|
||||
name: "Gmail".to_string(),
|
||||
url: Some("https://mail.google.com".to_string()),
|
||||
username: Some("user@gmail.com".to_string()),
|
||||
group: None,
|
||||
updated_at: "2024-06-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&manifest).unwrap();
|
||||
let decoded: Manifest = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.version, 1);
|
||||
assert!(decoded.entries.contains_key("deadbeef"));
|
||||
assert_eq!(decoded.entries["deadbeef"].name, "Gmail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_entry_id_is_8_hex_chars() {
|
||||
let id = generate_entry_id();
|
||||
assert_eq!(id.len(), 8);
|
||||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_search_case_insensitive() {
|
||||
let mut manifest = Manifest::new();
|
||||
manifest.add_entry(
|
||||
"id001".to_string(),
|
||||
ManifestEntry {
|
||||
name: "GitHub Account".to_string(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: None,
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
manifest.add_entry(
|
||||
"id002".to_string(),
|
||||
ManifestEntry {
|
||||
name: "Work Email".to_string(),
|
||||
url: Some("https://mail.example.com".to_string()),
|
||||
username: None,
|
||||
group: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// partial name match, case-insensitive
|
||||
let results = manifest.search("github");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].1.name, "GitHub Account");
|
||||
|
||||
// partial URL match
|
||||
let results = manifest.search("mail.example");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].1.name, "Work Email");
|
||||
|
||||
// no match
|
||||
let results = manifest.search("nonexistent");
|
||||
assert_eq!(results.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_deserializes_without_group_field() {
|
||||
// JSON from an older vault that has no "group" key — must deserialize with group: None
|
||||
let json = r#"{
|
||||
"name": "OldEntry",
|
||||
"url": "https://example.com",
|
||||
"username": "bob",
|
||||
"password": "hunter2",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}"#;
|
||||
let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field");
|
||||
assert_eq!(entry.name, "OldEntry");
|
||||
assert_eq!(entry.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_deserializes_without_group_field() {
|
||||
// JSON from an older manifest that has no "group" key — must deserialize with group: None
|
||||
let json = r#"{
|
||||
"name": "OldEntry",
|
||||
"url": "https://example.com",
|
||||
"username": "bob",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}"#;
|
||||
let me: ManifestEntry = serde_json::from_str(json)
|
||||
.expect("should deserialize ManifestEntry without group field");
|
||||
assert_eq!(me.name, "OldEntry");
|
||||
assert_eq!(me.group, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_with_group_round_trips() {
|
||||
let entry = Entry {
|
||||
name: "Work Laptop".to_string(),
|
||||
url: None,
|
||||
username: Some("alice@corp.example".to_string()),
|
||||
password: "p@ssw0rd".to_string(),
|
||||
notes: None,
|
||||
totp_secret: None,
|
||||
group: Some("work".to_string()),
|
||||
created_at: "2024-03-15T00:00:00Z".to_string(),
|
||||
updated_at: "2024-03-15T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
// The group field should be present in the JSON output
|
||||
assert!(json.contains("\"group\""), "serialized JSON should contain group field");
|
||||
assert!(json.contains("\"work\""), "serialized JSON should contain group value");
|
||||
|
||||
let decoded: Entry = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.name, "Work Laptop");
|
||||
assert_eq!(decoded.group, Some("work".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ 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
|
||||
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
|
||||
/// steganography -> serialization -> device keys.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RelicarioError {
|
||||
@@ -25,12 +25,8 @@ pub enum RelicarioError {
|
||||
#[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")]
|
||||
/// Authenticated decryption failed. Message intentionally opaque (audit M4).
|
||||
#[error("decryption failed")]
|
||||
Decrypt,
|
||||
|
||||
/// The binary ciphertext blob does not match the expected format (e.g.,
|
||||
@@ -40,10 +36,20 @@ pub enum RelicarioError {
|
||||
#[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),
|
||||
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
|
||||
UnsupportedFormatVersion { found: u8, expected: u8 },
|
||||
|
||||
/// An item was looked up by ID but does not exist in the manifest.
|
||||
#[error("item not found: {0}")]
|
||||
ItemNotFound(String),
|
||||
|
||||
/// A passphrase failed the strength gate at vault creation (audit H3).
|
||||
#[error("passphrase strength insufficient (score {score}/4)")]
|
||||
WeakPassphrase { score: u8 },
|
||||
|
||||
/// An attachment exceeded the per-attachment cap from VaultSettings.
|
||||
#[error("attachment too large: {size} bytes > {max} bytes max")]
|
||||
AttachmentTooLarge { size: u64, max: u64 },
|
||||
|
||||
/// A general error from the image steganography subsystem (imgsecret).
|
||||
/// Covers issues like failing to decode the carrier JPEG or failing to
|
||||
@@ -84,3 +90,44 @@ pub enum RelicarioError {
|
||||
|
||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||
pub type Result<T> = std::result::Result<T, RelicarioError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decrypt_error_message_is_opaque() {
|
||||
let err = RelicarioError::Decrypt;
|
||||
assert_eq!(format!("{}", err), "decryption failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_passphrase_carries_score() {
|
||||
let err = RelicarioError::WeakPassphrase { score: 1 };
|
||||
let s = format!("{}", err);
|
||||
assert!(s.contains("passphrase"));
|
||||
assert!(s.contains("strength"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_too_large_reports_sizes() {
|
||||
let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 };
|
||||
let s = format!("{}", err);
|
||||
assert!(s.contains("11000000"));
|
||||
assert!(s.contains("10485760"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_not_found_carries_id() {
|
||||
let err = RelicarioError::ItemNotFound("abc123".to_string());
|
||||
assert!(format!("{}", err).contains("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_format_version_reports_byte() {
|
||||
let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 };
|
||||
let s = format!("{}", err);
|
||||
assert!(s.contains("01") || s.contains("1"));
|
||||
assert!(s.contains("02") || s.contains("2"));
|
||||
}
|
||||
}
|
||||
|
||||
269
crates/relicario-core/src/generators.rs
Normal file
269
crates/relicario-core/src/generators.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
|
||||
//! eliminate modulo bias. Strength rating via zxcvbn.
|
||||
|
||||
use bip39::{Language, Mnemonic};
|
||||
use rand::distributions::{Distribution, Uniform};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset};
|
||||
|
||||
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
|
||||
const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?.";
|
||||
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
|
||||
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const DIGITS: &[u8] = b"0123456789";
|
||||
|
||||
pub fn generate_password(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
|
||||
match req {
|
||||
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||
random_password(*length, classes, symbol_charset)
|
||||
}
|
||||
GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format(
|
||||
"use generate_passphrase() for BIP39 requests".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn random_password(
|
||||
length: u32,
|
||||
classes: &CharClasses,
|
||||
symbol_charset: &SymbolCharset,
|
||||
) -> Result<Zeroizing<String>> {
|
||||
if length == 0 || length > 128 {
|
||||
return Err(RelicarioError::Format("length must be 1..=128".into()));
|
||||
}
|
||||
let mut charset: Vec<u8> = Vec::new();
|
||||
if classes.lower { charset.extend_from_slice(LOWER); }
|
||||
if classes.upper { charset.extend_from_slice(UPPER); }
|
||||
if classes.digits { charset.extend_from_slice(DIGITS); }
|
||||
if classes.symbols {
|
||||
let symbols: &[u8] = match symbol_charset {
|
||||
SymbolCharset::SafeOnly => SAFE_SYMBOLS,
|
||||
SymbolCharset::Extended => EXTENDED_SYMBOLS,
|
||||
SymbolCharset::Custom(s) => {
|
||||
if !s.is_ascii() {
|
||||
return Err(RelicarioError::Format(
|
||||
"SymbolCharset::Custom must be ASCII-only".into(),
|
||||
));
|
||||
}
|
||||
s.as_bytes()
|
||||
}
|
||||
};
|
||||
charset.extend_from_slice(symbols);
|
||||
}
|
||||
if charset.is_empty() {
|
||||
return Err(RelicarioError::Format("at least one character class required".into()));
|
||||
}
|
||||
|
||||
let dist = Uniform::from(0..charset.len());
|
||||
let mut rng = OsRng;
|
||||
let bytes: Vec<u8> = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect();
|
||||
Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset")))
|
||||
}
|
||||
|
||||
pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
|
||||
match req {
|
||||
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||
bip39_passphrase(*word_count, separator, *capitalization)
|
||||
}
|
||||
GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
|
||||
"use generate_password() for Random requests".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
|
||||
if !matches!(word_count, 3..=12) {
|
||||
return Err(RelicarioError::Format("word_count must be 3..=12".into()));
|
||||
}
|
||||
// bip39 v2 requires entropy 128–256 bits in multiples of 32 bits (4 bytes).
|
||||
// We always generate 128 bits (16 bytes) → 12 words, then take the first
|
||||
// word_count words. This gives full-entropy sourcing even for short passphrases.
|
||||
let mut entropy = Zeroizing::new([0u8; 16]);
|
||||
OsRng.fill_bytes(entropy.as_mut_slice());
|
||||
let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice())
|
||||
.map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?;
|
||||
let words: Vec<String> = m.words().take(word_count as usize).map(|w| {
|
||||
match cap {
|
||||
Capitalization::Lower => w.to_ascii_lowercase(),
|
||||
Capitalization::Upper => w.to_ascii_uppercase(),
|
||||
Capitalization::FirstOfEach | Capitalization::Title => {
|
||||
let mut chars = w.chars();
|
||||
chars.next().map(|c| c.to_ascii_uppercase().to_string())
|
||||
.unwrap_or_default() + chars.as_str()
|
||||
}
|
||||
Capitalization::Mixed => {
|
||||
w.chars().enumerate().map(|(i, c)| {
|
||||
if i % 2 == 0 { c.to_ascii_uppercase() } else { c }
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
}).collect();
|
||||
Ok(Zeroizing::new(words.join(separator)))
|
||||
}
|
||||
|
||||
/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StrengthEstimate {
|
||||
pub score: u8,
|
||||
pub guesses_log10: f64,
|
||||
}
|
||||
|
||||
pub fn rate_passphrase(p: &str) -> StrengthEstimate {
|
||||
let est = zxcvbn::zxcvbn(p, &[]);
|
||||
StrengthEstimate {
|
||||
score: est.score().into(),
|
||||
guesses_log10: est.guesses_log10(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strength gate at vault creation (audit H3): require score >= 3.
|
||||
pub fn validate_passphrase_strength(p: &str) -> Result<()> {
|
||||
let est = rate_passphrase(p);
|
||||
if est.score < 3 {
|
||||
return Err(RelicarioError::WeakPassphrase { score: est.score });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bip39_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bip39_default_is_5_space_separated_words() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 5,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
assert_eq!(pw.split(' ').count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_dash_separator() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 4,
|
||||
separator: "-".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
assert_eq!(pw.split('-').count(), 4);
|
||||
assert!(!pw.contains(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_first_of_each_capitalizes() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 5,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::FirstOfEach,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
for word in pw.split(' ') {
|
||||
let first = word.chars().next().unwrap();
|
||||
assert!(first.is_ascii_uppercase(), "word {word} should start uppercase");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_rejects_bad_word_count() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 2,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
assert!(generate_passphrase(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_passphrase_strong_one_passes_gate() {
|
||||
// 6-word bip39 passphrase
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 6,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
assert!(validate_passphrase_strength(&pw).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_passphrase_weak_fails_gate() {
|
||||
assert!(validate_passphrase_strength("password").is_err());
|
||||
assert!(validate_passphrase_strength("12345678").is_err());
|
||||
assert!(validate_passphrase_strength("hunter2").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn random_default_password_is_20_chars() {
|
||||
let req = GeneratorRequest::default();
|
||||
let pw = generate_password(&req).unwrap();
|
||||
assert_eq!(pw.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_length() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 0,
|
||||
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
assert!(generate_password(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_no_classes() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 8,
|
||||
classes: CharClasses { lower: false, upper: false, digits: false, symbols: false },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
assert!(generate_password(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_only_password_uses_lowercase() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 100,
|
||||
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
let pw = generate_password(&req).unwrap();
|
||||
assert!(pw.chars().all(|c| c.is_ascii_lowercase()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_symbols_excludes_quotes_and_brackets() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 128,
|
||||
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
let pw = generate_password(&req).unwrap();
|
||||
for c in pw.chars() {
|
||||
assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'),
|
||||
"safe charset must not include {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_charset_rejects_non_ascii() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 8,
|
||||
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
|
||||
symbol_charset: SymbolCharset::Custom("ñé".into()),
|
||||
};
|
||||
let err = generate_password(&req);
|
||||
assert!(err.is_err(), "non-ASCII custom charset must be rejected");
|
||||
}
|
||||
}
|
||||
124
crates/relicario-core/src/ids.rs
Normal file
124
crates/relicario-core/src/ids.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
//! Random and content-addressed identifiers for items, fields, and attachments.
|
||||
//!
|
||||
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
|
||||
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
|
||||
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
|
||||
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ItemId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct FieldId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AttachmentId(pub String);
|
||||
|
||||
impl ItemId {
|
||||
pub fn new() -> Self {
|
||||
let mut bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
impl Default for ItemId {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl FieldId {
|
||||
pub fn new() -> Self {
|
||||
let mut bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
impl Default for FieldId {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl AttachmentId {
|
||||
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
||||
let digest = Sha256::digest(plaintext);
|
||||
Self(hex::encode(&digest[..8]))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn item_id_is_16_hex_chars() {
|
||||
let id = ItemId::new();
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_ids_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..10_000 {
|
||||
assert!(seen.insert(ItemId::new().0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_id_is_16_hex_chars() {
|
||||
let id = FieldId::new();
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_ids_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..10_000 {
|
||||
assert!(seen.insert(FieldId::new().0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_is_deterministic() {
|
||||
let plaintext = b"hello world";
|
||||
let a = AttachmentId::from_plaintext(plaintext);
|
||||
let b = AttachmentId::from_plaintext(plaintext);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_changes_with_plaintext() {
|
||||
let a = AttachmentId::from_plaintext(b"hello");
|
||||
let b = AttachmentId::from_plaintext(b"world");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_is_16_hex_chars() {
|
||||
let id = AttachmentId::from_plaintext(b"any bytes");
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_serialize_as_bare_strings() {
|
||||
let item = ItemId("abcdef0123456789".to_string());
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
assert_eq!(json, "\"abcdef0123456789\"");
|
||||
|
||||
let parsed: ItemId = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, item);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,11 @@ const QUANT_STEP: f64 = 50.0;
|
||||
/// this cannot hold enough 8x8 blocks for reliable embedding.
|
||||
const MIN_DIMENSION: u32 = 100;
|
||||
|
||||
/// Maximum image dimension (width or height) in pixels. Images larger than
|
||||
/// this are rejected before full decode to prevent DoS via attacker-supplied
|
||||
/// oversized JPEGs (audit M3).
|
||||
pub const MAX_DIMENSION: u32 = 10_000;
|
||||
|
||||
/// Number of secret bits to embed: 256 bits = 32 bytes.
|
||||
const SECRET_BITS: usize = 256;
|
||||
|
||||
@@ -112,6 +117,64 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [
|
||||
(2, 3), // zig-zag 14-17
|
||||
];
|
||||
|
||||
// ─── Dimension guard ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk JPEG markers until we hit an SOF (start-of-frame) marker, which
|
||||
/// carries the image dimensions in bytes 5..=8 of its segment.
|
||||
///
|
||||
/// This peek does NOT decode any pixel data, so an oversized JPEG header is
|
||||
/// rejected in O(marker-count) time without allocating a frame buffer.
|
||||
fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> {
|
||||
let mut i = 0;
|
||||
while i + 1 < jpeg.len() {
|
||||
if jpeg[i] != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = jpeg[i + 1];
|
||||
match marker {
|
||||
0xD8 | 0xD9 => {
|
||||
i += 2;
|
||||
continue;
|
||||
} // SOI / EOI
|
||||
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => {
|
||||
// SOFn — height in [i+5..i+7], width in [i+7..i+9]
|
||||
if i + 8 >= jpeg.len() {
|
||||
return Err(RelicarioError::ImgSecret("truncated SOF marker".into()));
|
||||
}
|
||||
let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32;
|
||||
let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32;
|
||||
return Ok((width, height));
|
||||
}
|
||||
_ => {
|
||||
if i + 3 >= jpeg.len() {
|
||||
return Err(RelicarioError::ImgSecret("truncated marker segment".into()));
|
||||
}
|
||||
let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize;
|
||||
i += 2 + seg_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RelicarioError::ImgSecret(
|
||||
"no SOF marker found in JPEG".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Reject JPEGs that claim dimensions exceeding [`MAX_DIMENSION`].
|
||||
///
|
||||
/// Called at the entry point of both `embed` and `extract` to prevent
|
||||
/// attacker-supplied 32000×32000 images from wedging the WASM service worker
|
||||
/// during the expensive DCT extraction pass (audit M3).
|
||||
fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> {
|
||||
let (w, h) = peek_jpeg_dimensions(jpeg)?;
|
||||
if w > MAX_DIMENSION || h > MAX_DIMENSION {
|
||||
return Err(RelicarioError::ImgSecret(format!(
|
||||
"image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── YChannel ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The luminance (Y) channel of an image, stored as a flat array of f64 values.
|
||||
@@ -601,6 +664,7 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
||||
/// or does not have enough blocks for reliable embedding.
|
||||
/// - [`RelicarioError::ImgSecret`] if the image cannot be decoded or re-encoded.
|
||||
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
enforce_dimension_cap(carrier_jpeg)?;
|
||||
let mut y = extract_y_channel(carrier_jpeg)?;
|
||||
|
||||
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
|
||||
@@ -672,6 +736,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
/// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered
|
||||
/// (image was never watermarked, or was too heavily recompressed/cropped).
|
||||
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||
enforce_dimension_cap(jpeg_bytes)?;
|
||||
extract_with_crop_recovery(jpeg_bytes)
|
||||
}
|
||||
|
||||
@@ -1015,6 +1080,30 @@ mod tests {
|
||||
assert_eq!(extracted, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_image_without_full_decode() {
|
||||
// Synthesize a JPEG header claiming 20000x20000 dimensions.
|
||||
// The actual pixel data is irrelevant — the dimension peek should bail out
|
||||
// before decoding any pixels.
|
||||
let jpeg = build_oversized_jpeg_header(20_000, 20_000);
|
||||
let result = extract(&jpeg);
|
||||
assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension")));
|
||||
}
|
||||
|
||||
fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec<u8> {
|
||||
// SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI
|
||||
let mut v = vec![0xFF, 0xD8]; // SOI
|
||||
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0
|
||||
v.extend_from_slice(b"JFIF\0");
|
||||
v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
|
||||
v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0
|
||||
v.extend_from_slice(&height.to_be_bytes());
|
||||
v.extend_from_slice(&width.to_be_bytes());
|
||||
v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
|
||||
v.extend_from_slice(&[0xFF, 0xD9]); // EOI
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_extract_survives_10pct_crop() {
|
||||
let jpeg = make_test_jpeg(400, 300);
|
||||
|
||||
497
crates/relicario-core/src/item.rs
Normal file
497
crates/relicario-core/src/item.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
//! Item envelope, sections, and custom fields.
|
||||
//!
|
||||
//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing
|
||||
//! to a single tagged enum) so the kind can be queried without inspecting the value.
|
||||
//! Validation invariant: kind and value's discriminants must match — enforced at
|
||||
//! construction (`Field::new`) and during deserialization (`Field::validate`).
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
use crate::ids::{AttachmentId, FieldId};
|
||||
use crate::item_types::TotpConfig;
|
||||
use crate::time::MonthYear;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FieldKind {
|
||||
Text,
|
||||
Multiline,
|
||||
Password,
|
||||
Concealed,
|
||||
Url,
|
||||
Email,
|
||||
Phone,
|
||||
Date,
|
||||
MonthYear,
|
||||
Totp,
|
||||
Reference,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum FieldValue {
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Password(Zeroizing<String>),
|
||||
Concealed(Zeroizing<String>),
|
||||
Url(Url),
|
||||
Email(String),
|
||||
Phone(String),
|
||||
Date(NaiveDate),
|
||||
MonthYear(MonthYear),
|
||||
Totp(TotpConfig),
|
||||
Reference(AttachmentId),
|
||||
}
|
||||
|
||||
impl FieldValue {
|
||||
pub fn kind(&self) -> FieldKind {
|
||||
match self {
|
||||
FieldValue::Text(_) => FieldKind::Text,
|
||||
FieldValue::Multiline(_) => FieldKind::Multiline,
|
||||
FieldValue::Password(_) => FieldKind::Password,
|
||||
FieldValue::Concealed(_) => FieldKind::Concealed,
|
||||
FieldValue::Url(_) => FieldKind::Url,
|
||||
FieldValue::Email(_) => FieldKind::Email,
|
||||
FieldValue::Phone(_) => FieldKind::Phone,
|
||||
FieldValue::Date(_) => FieldKind::Date,
|
||||
FieldValue::MonthYear(_) => FieldKind::MonthYear,
|
||||
FieldValue::Totp(_) => FieldKind::Totp,
|
||||
FieldValue::Reference(_) => FieldKind::Reference,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this kind triggers field-history capture on update.
|
||||
pub fn is_history_tracked(&self) -> bool {
|
||||
matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Field {
|
||||
pub id: FieldId,
|
||||
pub label: String,
|
||||
pub kind: FieldKind,
|
||||
pub value: FieldValue,
|
||||
#[serde(default)]
|
||||
pub hidden_by_default: bool,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// Construct a field, deriving `kind` from `value`.
|
||||
pub fn new(label: String, value: FieldValue) -> Self {
|
||||
let kind = value.kind();
|
||||
Self {
|
||||
id: FieldId::new(),
|
||||
label,
|
||||
kind,
|
||||
value,
|
||||
hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify kind/value discriminants match. Called after deserialization.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.kind != self.value.kind() {
|
||||
return Err(RelicarioError::Format(format!(
|
||||
"field {}: kind {:?} does not match value discriminant {:?}",
|
||||
self.id.as_str(),
|
||||
self.kind,
|
||||
self.value.kind()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Section {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub fields: Vec<Field>,
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::attachment::AttachmentRef;
|
||||
use crate::ids::ItemId;
|
||||
use crate::item_types::{ItemCore, ItemType};
|
||||
use crate::time::now_unix;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldHistoryEntry {
|
||||
pub value: Zeroizing<String>,
|
||||
pub replaced_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
pub title: String,
|
||||
pub r#type: ItemType,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created: i64,
|
||||
pub modified: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trashed_at: Option<i64>,
|
||||
pub core: ItemCore,
|
||||
#[serde(default)]
|
||||
pub sections: Vec<Section>,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<AttachmentRef>,
|
||||
#[serde(default)]
|
||||
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Construct a new Item from a typed core; auto-fills id, type, timestamps.
|
||||
pub fn new(title: String, core: ItemCore) -> Self {
|
||||
let now = now_unix();
|
||||
let r#type = core.item_type();
|
||||
Self {
|
||||
id: ItemId::new(),
|
||||
title,
|
||||
r#type,
|
||||
tags: Vec::new(),
|
||||
favorite: false,
|
||||
group: None,
|
||||
notes: None,
|
||||
created: now,
|
||||
modified: now,
|
||||
trashed_at: None,
|
||||
core,
|
||||
sections: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
field_history: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a custom field's value, capturing the previous value into
|
||||
/// field_history if the field's kind is history-tracked.
|
||||
pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> {
|
||||
for section in &mut self.sections {
|
||||
if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) {
|
||||
if field.value.kind() != new_value.kind() {
|
||||
return Err(RelicarioError::Format(format!(
|
||||
"field {}: cannot change kind from {:?} to {:?}",
|
||||
field.id.as_str(), field.value.kind(), new_value.kind()
|
||||
)));
|
||||
}
|
||||
if field.value.is_history_tracked() {
|
||||
let serialized = serialize_history_value(&field.value)?;
|
||||
self.field_history
|
||||
.entry(field.id.clone())
|
||||
.or_default()
|
||||
.push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() });
|
||||
}
|
||||
field.value = new_value;
|
||||
self.modified = now_unix();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(RelicarioError::Format(format!("field {} not found", field_id.as_str())))
|
||||
}
|
||||
|
||||
pub fn soft_delete(&mut self) {
|
||||
self.trashed_at = Some(now_unix());
|
||||
self.modified = now_unix();
|
||||
}
|
||||
|
||||
pub fn restore(&mut self) {
|
||||
self.trashed_at = None;
|
||||
self.modified = now_unix();
|
||||
}
|
||||
|
||||
pub fn is_trashed(&self) -> bool {
|
||||
self.trashed_at.is_some()
|
||||
}
|
||||
|
||||
pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) {
|
||||
use crate::settings::HistoryRetention;
|
||||
for history in self.field_history.values_mut() {
|
||||
match retention {
|
||||
HistoryRetention::Forever => {}
|
||||
HistoryRetention::LastN(n) => {
|
||||
let n = *n as usize;
|
||||
if history.len() > n {
|
||||
let drop_count = history.len() - n;
|
||||
history.drain(..drop_count);
|
||||
}
|
||||
}
|
||||
HistoryRetention::Days(d) => {
|
||||
let cutoff = now - (*d as i64) * 86_400;
|
||||
history.retain(|e| e.replaced_at > cutoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a FieldValue to the string form stored in field_history.
|
||||
fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
||||
let s = match value {
|
||||
FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()),
|
||||
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
|
||||
FieldValue::Totp(cfg) => {
|
||||
// Store the base32-encoded secret string for human-recognizability.
|
||||
let s = base32_encode(&cfg.secret);
|
||||
Zeroizing::new(s)
|
||||
}
|
||||
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
|
||||
};
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
|
||||
fn base32_encode(bytes: &[u8]) -> String {
|
||||
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut out = String::new();
|
||||
let mut buffer: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &b in bytes {
|
||||
buffer = (buffer << 8) | (b as u32);
|
||||
bits += 8;
|
||||
while bits >= 5 {
|
||||
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
|
||||
out.push(ALPHA[idx] as char);
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
|
||||
out.push(ALPHA[idx] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn field_value_kind_matches() {
|
||||
let v = FieldValue::Text("hello".into());
|
||||
assert_eq!(v.kind(), FieldKind::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_field_marked_history_tracked() {
|
||||
assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked());
|
||||
assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked());
|
||||
assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked());
|
||||
assert!(!FieldValue::Text("x".into()).is_history_tracked());
|
||||
assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_new_derives_kind_from_value() {
|
||||
let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into())));
|
||||
assert_eq!(f.kind, FieldKind::Password);
|
||||
assert!(f.hidden_by_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_new_text_not_hidden() {
|
||||
let f = Field::new("Username".into(), FieldValue::Text("alice".into()));
|
||||
assert!(!f.hidden_by_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_validate_catches_kind_value_mismatch() {
|
||||
let f = Field {
|
||||
id: FieldId::new(),
|
||||
label: "x".into(),
|
||||
kind: FieldKind::Password,
|
||||
value: FieldValue::Text("not actually a password".into()),
|
||||
hidden_by_default: false,
|
||||
};
|
||||
assert!(f.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_round_trips() {
|
||||
let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into())));
|
||||
let json = serde_json::to_string(&f).unwrap();
|
||||
let parsed: Field = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.label, "Recovery code");
|
||||
assert_eq!(parsed.kind, FieldKind::Concealed);
|
||||
parsed.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_round_trip() {
|
||||
let s = Section {
|
||||
name: Some("Recovery codes".into()),
|
||||
fields: vec![
|
||||
Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))),
|
||||
Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))),
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let parsed: Section = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
|
||||
assert_eq!(parsed.fields.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_item_has_timestamps_and_id() {
|
||||
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
|
||||
let item = Item::new("note".into(), core);
|
||||
assert_eq!(item.id.0.len(), 16);
|
||||
assert_eq!(item.r#type, ItemType::SecureNote);
|
||||
assert!(item.created > 0);
|
||||
assert_eq!(item.created, item.modified);
|
||||
assert!(item.field_history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_delete_and_restore_round_trip() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
assert!(!item.is_trashed());
|
||||
item.soft_delete();
|
||||
assert!(item.is_trashed());
|
||||
item.restore();
|
||||
assert!(!item.is_trashed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_value_captures_history_for_password() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into())));
|
||||
let pw_id = pw_field.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![pw_field] });
|
||||
|
||||
item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap();
|
||||
let hist = item.field_history.get(&pw_id).expect("history should exist");
|
||||
assert_eq!(hist.len(), 1);
|
||||
assert_eq!(hist[0].value.as_str(), "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_value_does_not_capture_history_for_text() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
let f = Field::new("nickname".into(), FieldValue::Text("a".into()));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap();
|
||||
assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_value_rejects_kind_change() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
let f = Field::new("x".into(), FieldValue::Text("a".into()));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into())));
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_serializes_with_minimal_optional_fields() {
|
||||
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
|
||||
let item = Item::new("note".into(), core);
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
// No "trashed_at" or "group" or "notes" should appear when None
|
||||
assert!(!json.contains("trashed_at"));
|
||||
assert!(!json.contains("\"group\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_item_round_trip() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: Some(Url::parse("https://github.com").unwrap()),
|
||||
totp: None,
|
||||
});
|
||||
let mut item = Item::new("GitHub".into(), core);
|
||||
item.tags = vec!["work".into()];
|
||||
item.favorite = true;
|
||||
item.notes = Some("notes".into());
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
let parsed: Item = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.title, "GitHub");
|
||||
assert_eq!(parsed.tags, vec!["work".to_string()]);
|
||||
assert!(parsed.favorite);
|
||||
match parsed.core {
|
||||
ItemCore::Login(l) => {
|
||||
assert_eq!(l.username.as_deref(), Some("alice"));
|
||||
}
|
||||
other => panic!("expected Login, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_history_keeps_last_n() {
|
||||
use crate::settings::HistoryRetention;
|
||||
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("x".into(), core);
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
for i in 1..=5 {
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
|
||||
}
|
||||
assert_eq!(item.field_history[&fid].len(), 5);
|
||||
|
||||
item.prune_history(&HistoryRetention::LastN(3), 0);
|
||||
assert_eq!(item.field_history[&fid].len(), 3);
|
||||
// Keeps the MOST RECENT 3
|
||||
assert_eq!(item.field_history[&fid][0].value.as_str(), "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_history_drops_old_entries_by_days() {
|
||||
use crate::settings::HistoryRetention;
|
||||
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("x".into(), core);
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
let now = 1_000_000_000;
|
||||
item.field_history.insert(fid.clone(), vec![
|
||||
FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 },
|
||||
FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 },
|
||||
]);
|
||||
|
||||
item.prune_history(&HistoryRetention::Days(30), now);
|
||||
assert_eq!(item.field_history[&fid].len(), 1);
|
||||
assert_eq!(item.field_history[&fid][0].value.as_str(), "recent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_history_forever_keeps_all() {
|
||||
use crate::settings::HistoryRetention;
|
||||
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("x".into(), core);
|
||||
item.field_history.insert(FieldId::new(), vec![
|
||||
FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 },
|
||||
FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 },
|
||||
]);
|
||||
item.prune_history(&HistoryRetention::Forever, 1_000_000_000);
|
||||
assert_eq!(item.field_history.values().next().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
68
crates/relicario-core/src/item_types/card.rs
Normal file
68
crates/relicario-core/src/item_types/card.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::time::MonthYear;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CardCore {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number: Option<Zeroizing<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holder: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiry: Option<MonthYear>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cvv: Option<Zeroizing<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pin: Option<Zeroizing<String>>,
|
||||
#[serde(default)]
|
||||
pub kind: CardKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CardKind {
|
||||
#[default]
|
||||
Credit,
|
||||
Debit,
|
||||
Gift,
|
||||
Loyalty,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn card_full_round_trip() {
|
||||
let card = CardCore {
|
||||
number: Some(Zeroizing::new("4111111111111111".into())),
|
||||
holder: Some("Alice Doe".into()),
|
||||
expiry: Some(MonthYear::new(12, 2030).unwrap()),
|
||||
cvv: Some(Zeroizing::new("123".into())),
|
||||
pin: Some(Zeroizing::new("0000".into())),
|
||||
kind: CardKind::Credit,
|
||||
};
|
||||
let json = serde_json::to_string(&card).unwrap();
|
||||
let parsed: CardCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.holder.as_deref(), Some("Alice Doe"));
|
||||
assert_eq!(parsed.kind, CardKind::Credit);
|
||||
assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_kind_default_is_credit() {
|
||||
let json = "{}";
|
||||
let card: CardCore = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(card.kind, CardKind::Credit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_kind_serializes_snake_case() {
|
||||
let json = serde_json::to_string(&CardKind::Loyalty).unwrap();
|
||||
assert_eq!(json, "\"loyalty\"");
|
||||
}
|
||||
}
|
||||
40
crates/relicario-core/src/item_types/document.rs
Normal file
40
crates/relicario-core/src/item_types/document.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Document: filename + mime + pointer to the primary attachment blob.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::AttachmentId;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DocumentCore {
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub primary_attachment: AttachmentId,
|
||||
}
|
||||
|
||||
impl Default for DocumentCore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filename: String::new(),
|
||||
mime_type: "application/octet-stream".into(),
|
||||
primary_attachment: AttachmentId(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn document_round_trip() {
|
||||
let doc = DocumentCore {
|
||||
filename: "passport.pdf".into(),
|
||||
mime_type: "application/pdf".into(),
|
||||
primary_attachment: AttachmentId("0123456789abcdef".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
let parsed: DocumentCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.filename, "passport.pdf");
|
||||
assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef");
|
||||
}
|
||||
}
|
||||
45
crates/relicario-core/src/item_types/identity.rs
Normal file
45
crates/relicario-core/src/item_types/identity.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Identity: name, address, phone, email, date-of-birth.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct IdentityCore {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub address: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_of_birth: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_full_round_trip() {
|
||||
let id = IdentityCore {
|
||||
full_name: Some("Alice Doe".into()),
|
||||
address: Some("123 Main St\nAnytown".into()),
|
||||
phone: Some("+1-555-0100".into()),
|
||||
email: Some("alice@example.com".into()),
|
||||
date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18),
|
||||
};
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
let parsed: IdentityCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe"));
|
||||
assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_identity_omits_all_fields() {
|
||||
let id = IdentityCore::default();
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
assert_eq!(json, "{}");
|
||||
}
|
||||
}
|
||||
42
crates/relicario-core/src/item_types/key.rs
Normal file
42
crates/relicario-core/src/item_types/key.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Key: arbitrary key material (Zeroizing), label, public key, algorithm.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct KeyCore {
|
||||
pub key_material: Zeroizing<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub algorithm: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_round_trip() {
|
||||
let k = KeyCore {
|
||||
key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()),
|
||||
label: Some("yubikey-backup".into()),
|
||||
public_key: Some("ssh-ed25519 AAAAC3...".into()),
|
||||
algorithm: Some("ed25519".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&k).unwrap();
|
||||
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.key_material.starts_with("-----BEGIN"));
|
||||
assert_eq!(parsed.algorithm.as_deref(), Some("ed25519"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_key_material_round_trips() {
|
||||
let k = KeyCore::default();
|
||||
let json = serde_json::to_string(&k).unwrap();
|
||||
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.key_material.is_empty());
|
||||
}
|
||||
}
|
||||
63
crates/relicario-core/src/item_types/login.rs
Normal file
63
crates/relicario-core/src/item_types/login.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Login item core: username, password (Zeroizing), URL, optional TOTP.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::item_types::TotpConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LoginCore {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<Zeroizing<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub totp: Option<TotpConfig>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_login_round_trips() {
|
||||
let login = LoginCore::default();
|
||||
let json = serde_json::to_string(&login).unwrap();
|
||||
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.username.is_none());
|
||||
assert!(parsed.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_login_round_trips() {
|
||||
let login = LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: Some(Url::parse("https://github.com/login").unwrap()),
|
||||
totp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&login).unwrap();
|
||||
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.username.as_deref(), Some("alice"));
|
||||
assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2"));
|
||||
assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_fields_dont_appear_in_json() {
|
||||
let login = LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
url: None,
|
||||
totp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&login).unwrap();
|
||||
assert!(!json.contains("password"));
|
||||
assert!(!json.contains("url"));
|
||||
assert!(!json.contains("totp"));
|
||||
assert!(json.contains("alice"));
|
||||
}
|
||||
}
|
||||
127
crates/relicario-core/src/item_types/mod.rs
Normal file
127
crates/relicario-core/src/item_types/mod.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Per-type "core" structs for typed items.
|
||||
//!
|
||||
//! Each variant lives in its own submodule. The `ItemCore` enum + match
|
||||
//! exhaustiveness is the extension mechanism — adding a new variant later
|
||||
//! means: create the submodule, add the enum variant, fix the match arms
|
||||
//! the compiler points at, register the popup form (Plan 1C).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod login;
|
||||
pub mod secure_note;
|
||||
pub mod identity;
|
||||
pub mod card;
|
||||
pub mod key;
|
||||
pub mod document;
|
||||
pub mod totp;
|
||||
|
||||
pub use login::LoginCore;
|
||||
pub use secure_note::SecureNoteCore;
|
||||
pub use identity::IdentityCore;
|
||||
pub use card::{CardCore, CardKind};
|
||||
pub use key::KeyCore;
|
||||
pub use document::DocumentCore;
|
||||
pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind, compute_totp_code};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ItemType {
|
||||
Login,
|
||||
SecureNote,
|
||||
Identity,
|
||||
Card,
|
||||
Key,
|
||||
Document,
|
||||
Totp,
|
||||
}
|
||||
|
||||
// INVARIANT: no *Core struct may have a field serialized as "type" —
|
||||
// that key is reserved for serde's internal tag. Use "kind" for
|
||||
// type-discriminant fields within core structs (CardKind, TotpKind).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ItemCore {
|
||||
Login(LoginCore),
|
||||
SecureNote(SecureNoteCore),
|
||||
Identity(IdentityCore),
|
||||
Card(CardCore),
|
||||
Key(KeyCore),
|
||||
Document(DocumentCore),
|
||||
Totp(TotpCore),
|
||||
}
|
||||
|
||||
impl ItemCore {
|
||||
pub fn item_type(&self) -> ItemType {
|
||||
match self {
|
||||
ItemCore::Login(_) => ItemType::Login,
|
||||
ItemCore::SecureNote(_) => ItemType::SecureNote,
|
||||
ItemCore::Identity(_) => ItemType::Identity,
|
||||
ItemCore::Card(_) => ItemType::Card,
|
||||
ItemCore::Key(_) => ItemType::Key,
|
||||
ItemCore::Document(_) => ItemType::Document,
|
||||
ItemCore::Totp(_) => ItemType::Totp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn item_type_serializes_snake_case() {
|
||||
let json = serde_json::to_string(&ItemType::SecureNote).unwrap();
|
||||
assert_eq!(json, "\"secure_note\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_core_login_round_trip_via_tag() {
|
||||
use zeroize::Zeroizing;
|
||||
let core = ItemCore::Login(LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: None,
|
||||
totp: None,
|
||||
});
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
// Tag-based: outer object has "type": "login"
|
||||
assert!(json.contains("\"type\":\"login\""));
|
||||
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.item_type(), ItemType::Login);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_core_secure_note_round_trip_via_tag() {
|
||||
use zeroize::Zeroizing;
|
||||
let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) });
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
assert!(json.contains("\"type\":\"secure_note\""));
|
||||
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.item_type(), ItemType::SecureNote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_core_round_trips_for_all_seven_types() {
|
||||
use crate::ids::AttachmentId;
|
||||
|
||||
let cores = vec![
|
||||
ItemCore::Login(LoginCore::default()),
|
||||
ItemCore::SecureNote(SecureNoteCore::default()),
|
||||
ItemCore::Identity(IdentityCore::default()),
|
||||
ItemCore::Card(CardCore::default()),
|
||||
ItemCore::Key(KeyCore::default()),
|
||||
ItemCore::Document(DocumentCore {
|
||||
filename: "x".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
primary_attachment: AttachmentId("0123456789abcdef".into()),
|
||||
}),
|
||||
ItemCore::Totp(TotpCore::default()),
|
||||
];
|
||||
for core in cores {
|
||||
let expected_type = core.item_type();
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed");
|
||||
assert_eq!(parsed.item_type(), expected_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/relicario-core/src/item_types/secure_note.rs
Normal file
30
crates/relicario-core/src/item_types/secure_note.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! Secure note: just a multiline body, Zeroizing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SecureNoteCore {
|
||||
pub body: Zeroizing<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn secure_note_round_trips() {
|
||||
let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) };
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.body.as_str(), "a multi\nline note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_round_trips() {
|
||||
let note = SecureNoteCore::default();
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.body.is_empty());
|
||||
}
|
||||
}
|
||||
170
crates/relicario-core/src/item_types/totp.rs
Normal file
170
crates/relicario-core/src/item_types/totp.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1 as HmacSha1;
|
||||
use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TotpCore {
|
||||
pub config: TotpConfig,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub issuer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TotpConfig {
|
||||
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
||||
pub secret: Zeroizing<Vec<u8>>,
|
||||
pub algorithm: TotpAlgorithm,
|
||||
pub digits: u8,
|
||||
pub period_seconds: u32,
|
||||
pub kind: TotpKind,
|
||||
}
|
||||
|
||||
impl Default for TotpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
secret: Zeroizing::new(Vec::new()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TotpAlgorithm {
|
||||
#[default]
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TotpKind {
|
||||
Totp,
|
||||
Hotp { counter: u64 },
|
||||
Steam,
|
||||
}
|
||||
|
||||
impl Default for TotpKind {
|
||||
fn default() -> Self { TotpKind::Totp }
|
||||
}
|
||||
|
||||
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
|
||||
///
|
||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||
/// For HOTP: uses the `counter` carried in the variant.
|
||||
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
||||
let counter = match config.kind {
|
||||
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
||||
TotpKind::Hotp { counter } => counter,
|
||||
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
||||
};
|
||||
let counter_bytes = counter.to_be_bytes();
|
||||
let hmac_out: Vec<u8> = match config.algorithm {
|
||||
TotpAlgorithm::Sha1 => {
|
||||
let mut mac = Hmac::<HmacSha1>::new_from_slice(&config.secret)
|
||||
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||
mac.update(&counter_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
TotpAlgorithm::Sha256 => {
|
||||
let mut mac = Hmac::<HmacSha256>::new_from_slice(&config.secret)
|
||||
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||
mac.update(&counter_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
TotpAlgorithm::Sha512 => {
|
||||
let mut mac = Hmac::<HmacSha512>::new_from_slice(&config.secret)
|
||||
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||
mac.update(&counter_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
};
|
||||
let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize;
|
||||
let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24)
|
||||
| ((hmac_out[offset + 1] as u32) << 16)
|
||||
| ((hmac_out[offset + 2] as u32) << 8)
|
||||
| (hmac_out[offset + 3] as u32);
|
||||
let modulus = 10u32.pow(config.digits as u32);
|
||||
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compute_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rfc6238_sha1_vector_59() {
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 8,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
};
|
||||
assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn totp_default_is_sha1_6_30_totp() {
|
||||
let cfg = TotpConfig::default();
|
||||
assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
|
||||
assert_eq!(cfg.digits, 6);
|
||||
assert_eq!(cfg.period_seconds, 30);
|
||||
assert_eq!(cfg.kind, TotpKind::Totp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_round_trip() {
|
||||
let core = TotpCore {
|
||||
config: TotpConfig {
|
||||
secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
|
||||
algorithm: TotpAlgorithm::Sha256,
|
||||
digits: 8,
|
||||
period_seconds: 60,
|
||||
kind: TotpKind::Totp,
|
||||
},
|
||||
issuer: Some("github".into()),
|
||||
label: Some("alice@github".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
let parsed: TotpCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.config.digits, 8);
|
||||
assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
|
||||
assert_eq!(parsed.issuer.as_deref(), Some("github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotp_carries_counter() {
|
||||
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
||||
match parsed.kind {
|
||||
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
||||
other => panic!("expected Hotp, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_kind_serializes() {
|
||||
let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
assert!(json.contains("steam"));
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,22 @@
|
||||
//!
|
||||
//! ## 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.
|
||||
//! - [`error`] — The unified error type ([`RelicarioError`]).
|
||||
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
||||
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
||||
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
||||
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
||||
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
||||
//! `ItemCore`/`ItemType` enums.
|
||||
//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`,
|
||||
//! `FieldHistoryEntry`.
|
||||
//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers.
|
||||
//! - [`manifest`] — Browse-without-decrypt index (schema_version 2).
|
||||
//! - [`settings`] — Vault-level retention, generator defaults, attachment caps.
|
||||
//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn
|
||||
//! strength gate.
|
||||
//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings).
|
||||
//! - [`imgsecret`] — DCT-based steganography for the second auth factor.
|
||||
//!
|
||||
//! ## Crypto pipeline
|
||||
//!
|
||||
@@ -36,12 +41,39 @@ pub mod error;
|
||||
pub use error::{RelicarioError, Result};
|
||||
|
||||
pub mod crypto;
|
||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
||||
|
||||
pub mod entry;
|
||||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||||
pub mod ids;
|
||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||
|
||||
pub mod time;
|
||||
pub use time::{now_unix, MonthYear};
|
||||
|
||||
pub mod item_types;
|
||||
pub use item_types::{ItemCore, ItemType};
|
||||
|
||||
pub mod item;
|
||||
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
|
||||
|
||||
pub mod attachment;
|
||||
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};
|
||||
|
||||
pub mod manifest;
|
||||
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||
SymbolCharset, TrashRetention, VaultSettings,
|
||||
};
|
||||
|
||||
pub mod generators;
|
||||
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
|
||||
|
||||
pub mod vault;
|
||||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
||||
pub use vault::{
|
||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||
encrypt_item, encrypt_manifest, encrypt_settings,
|
||||
};
|
||||
|
||||
pub mod imgsecret;
|
||||
|
||||
159
crates/relicario-core/src/manifest.rs
Normal file
159
crates/relicario-core/src/manifest.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
//! New typed-item manifest. Lives next to the old entry.rs Manifest
|
||||
//! during this rewrite; entry.rs is deleted in Task 25.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::attachment::AttachmentSummary;
|
||||
use crate::ids::ItemId;
|
||||
use crate::item::Item;
|
||||
use crate::item_types::ItemType;
|
||||
|
||||
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub schema_version: u32,
|
||||
pub items: HashMap<ItemId, ManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestEntry {
|
||||
pub id: ItemId,
|
||||
pub r#type: ItemType,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_hint: Option<String>,
|
||||
pub modified: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trashed_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub attachment_summaries: Vec<AttachmentSummary>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn new() -> Self {
|
||||
Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, item: &Item) {
|
||||
let entry = ManifestEntry::from_item(item);
|
||||
self.items.insert(item.id.clone(), entry);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &ItemId) -> Option<ManifestEntry> {
|
||||
self.items.remove(id)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> {
|
||||
self.items.get(id)
|
||||
}
|
||||
|
||||
/// Case-insensitive substring match on title and tags.
|
||||
pub fn search(&self, query: &str) -> Vec<&ManifestEntry> {
|
||||
let q = query.to_lowercase();
|
||||
self.items
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.title.to_lowercase().contains(&q)
|
||||
|| e.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Manifest {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
pub fn from_item(item: &Item) -> Self {
|
||||
Self {
|
||||
id: item.id.clone(),
|
||||
r#type: item.r#type,
|
||||
title: item.title.clone(),
|
||||
tags: item.tags.clone(),
|
||||
favorite: item.favorite,
|
||||
group: item.group.clone(),
|
||||
icon_hint: derive_icon_hint(item),
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive an icon hint string from an item — for Login items, this is the URL hostname.
|
||||
fn derive_icon_hint(item: &Item) -> Option<String> {
|
||||
use crate::item_types::ItemCore;
|
||||
match &item.core {
|
||||
ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
|
||||
|
||||
#[test]
|
||||
fn empty_manifest_has_schema_v2() {
|
||||
let m = Manifest::new();
|
||||
assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION);
|
||||
assert!(m.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_search() {
|
||||
let mut m = Manifest::new();
|
||||
let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default()));
|
||||
item.tags = vec!["work".into()];
|
||||
m.upsert(&item);
|
||||
|
||||
let results = m.search("github");
|
||||
assert_eq!(results.len(), 1);
|
||||
let by_tag = m.search("work");
|
||||
assert_eq!(by_tag.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_hint_is_login_url_host() {
|
||||
use url::Url;
|
||||
let mut m = Manifest::new();
|
||||
let core = ItemCore::Login(LoginCore {
|
||||
url: Some(Url::parse("https://api.github.com/login").unwrap()),
|
||||
..Default::default()
|
||||
});
|
||||
let item = Item::new("X".into(), core);
|
||||
m.upsert(&item);
|
||||
let entry = m.items.values().next().unwrap();
|
||||
assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_hint_is_none_for_non_login() {
|
||||
let mut m = Manifest::new();
|
||||
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
m.upsert(&item);
|
||||
let entry = m.items.values().next().unwrap();
|
||||
assert!(entry.icon_hint.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_round_trips() {
|
||||
let mut m = Manifest::new();
|
||||
let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
m.upsert(&item);
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
let parsed: Manifest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION);
|
||||
assert_eq!(parsed.items.len(), 1);
|
||||
}
|
||||
}
|
||||
184
crates/relicario-core/src/settings.rs
Normal file
184
crates/relicario-core/src/settings.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Vault-level settings: trash retention, history retention, generator
|
||||
//! defaults, attachment caps, autofill TOFU acks.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VaultSettings {
|
||||
pub trash_retention: TrashRetention,
|
||||
pub field_history_retention: HistoryRetention,
|
||||
pub generator_defaults: GeneratorRequest,
|
||||
pub attachment_caps: AttachmentCaps,
|
||||
/// hostname → unix-seconds first-acked
|
||||
#[serde(default)]
|
||||
pub autofill_origin_acks: HashMap<String, i64>,
|
||||
}
|
||||
|
||||
impl Default for VaultSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
trash_retention: TrashRetention::Days(30),
|
||||
field_history_retention: HistoryRetention::Forever,
|
||||
generator_defaults: GeneratorRequest::default(),
|
||||
attachment_caps: AttachmentCaps::default(),
|
||||
autofill_origin_acks: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum TrashRetention {
|
||||
Days(u32),
|
||||
Forever,
|
||||
}
|
||||
|
||||
impl TrashRetention {
|
||||
pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool {
|
||||
match self {
|
||||
TrashRetention::Forever => false,
|
||||
TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum HistoryRetention {
|
||||
LastN(u32),
|
||||
Days(u32),
|
||||
Forever,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum GeneratorRequest {
|
||||
Bip39 {
|
||||
word_count: u32,
|
||||
separator: String,
|
||||
capitalization: Capitalization,
|
||||
},
|
||||
Random {
|
||||
length: u32,
|
||||
classes: CharClasses,
|
||||
symbol_charset: SymbolCharset,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for GeneratorRequest {
|
||||
fn default() -> Self {
|
||||
GeneratorRequest::Random {
|
||||
length: 20,
|
||||
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Capitalization {
|
||||
Lower,
|
||||
Upper,
|
||||
FirstOfEach,
|
||||
Title,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CharClasses {
|
||||
pub lower: bool,
|
||||
pub upper: bool,
|
||||
pub digits: bool,
|
||||
pub symbols: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum SymbolCharset {
|
||||
SafeOnly,
|
||||
Extended,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct AttachmentCaps {
|
||||
pub per_attachment_max_bytes: u64,
|
||||
pub per_item_max_count: u32,
|
||||
pub per_vault_soft_cap_bytes: u64,
|
||||
pub per_vault_hard_cap_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for AttachmentCaps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
per_attachment_max_bytes: 10 * 1024 * 1024,
|
||||
per_item_max_count: 20,
|
||||
per_vault_soft_cap_bytes: 100 * 1024 * 1024,
|
||||
per_vault_hard_cap_bytes: 500 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_spec() {
|
||||
let s = VaultSettings::default();
|
||||
assert!(matches!(s.trash_retention, TrashRetention::Days(30)));
|
||||
assert!(matches!(s.field_history_retention, HistoryRetention::Forever));
|
||||
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
|
||||
assert_eq!(s.attachment_caps.per_item_max_count, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_retention_purges_after_days() {
|
||||
let r = TrashRetention::Days(30);
|
||||
let now = 1_000_000_000;
|
||||
let recently_trashed = now - 29 * 86_400;
|
||||
let long_trashed = now - 31 * 86_400;
|
||||
assert!(!r.should_purge(recently_trashed, now));
|
||||
assert!(r.should_purge(long_trashed, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_retention_forever_never_purges() {
|
||||
let r = TrashRetention::Forever;
|
||||
assert!(!r.should_purge(0, 1_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip() {
|
||||
let s = VaultSettings::default();
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let parsed: VaultSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.attachment_caps.per_attachment_max_bytes,
|
||||
s.attachment_caps.per_attachment_max_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_generator_default_is_20_safe() {
|
||||
match VaultSettings::default().generator_defaults {
|
||||
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||
assert_eq!(length, 20);
|
||||
assert!(classes.lower && classes.upper && classes.digits && classes.symbols);
|
||||
assert!(matches!(symbol_charset, SymbolCharset::SafeOnly));
|
||||
}
|
||||
_ => panic!("expected Random default"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_charset_custom_round_trips() {
|
||||
let c = SymbolCharset::Custom("!@#".into());
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let parsed: SymbolCharset = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
SymbolCharset::Custom(s) => assert_eq!(s, "!@#"),
|
||||
other => panic!("expected Custom, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
63
crates/relicario-core/src/time.rs
Normal file
63
crates/relicario-core/src/time.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Time helpers and the `MonthYear` type used for card expiries.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current Unix timestamp in seconds.
|
||||
pub fn now_unix() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
/// Month + year (1-12 / e.g. 2026). Used for card expiries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MonthYear {
|
||||
pub month: u8,
|
||||
pub year: u16,
|
||||
}
|
||||
|
||||
impl MonthYear {
|
||||
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
|
||||
if !(1..=12).contains(&month) {
|
||||
return Err("month must be 1..=12");
|
||||
}
|
||||
if year < 2000 || year > 2099 {
|
||||
return Err("year must be 2000..=2099");
|
||||
}
|
||||
Ok(Self { month, year })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn now_unix_is_positive_and_recent() {
|
||||
let t = now_unix();
|
||||
assert!(t > 1_700_000_000); // after late 2023
|
||||
assert!(t < 4_000_000_000); // before 2096
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_year_constructor_rejects_bad_month() {
|
||||
assert!(MonthYear::new(0, 2026).is_err());
|
||||
assert!(MonthYear::new(13, 2026).is_err());
|
||||
assert!(MonthYear::new(1, 2026).is_ok());
|
||||
assert!(MonthYear::new(12, 2026).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_year_constructor_rejects_bad_year() {
|
||||
assert!(MonthYear::new(1, 1999).is_err());
|
||||
assert!(MonthYear::new(1, 2100).is_err());
|
||||
assert!(MonthYear::new(1, 2000).is_ok());
|
||||
assert!(MonthYear::new(1, 2099).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_year_round_trips_through_json() {
|
||||
let my = MonthYear::new(7, 2030).unwrap();
|
||||
let json = serde_json::to_string(&my).unwrap();
|
||||
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, my);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +1,90 @@
|
||||
//! Typed encryption/decryption wrappers for vault entries and manifests.
|
||||
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
|
||||
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
|
||||
//!
|
||||
//! 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).
|
||||
//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
|
||||
//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
|
||||
//! Plan 1B switches to the new helpers.
|
||||
|
||||
use crate::crypto;
|
||||
use crate::entry::{Entry, Manifest};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::crypto::{decrypt, encrypt};
|
||||
use crate::error::Result;
|
||||
use crate::item::Item;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::settings::VaultSettings;
|
||||
|
||||
/// 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)
|
||||
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(item)?;
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(master_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let item: Item = serde_json::from_slice(&plaintext)?;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(manifest)?;
|
||||
crypto::encrypt(master_key, &json)
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(master_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let manifest: Manifest = serde_json::from_slice(&plaintext)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(settings)?;
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(master_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<VaultSettings> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entry::ManifestEntry;
|
||||
use crate::item_types::{ItemCore, SecureNoteCore};
|
||||
|
||||
fn test_key_a() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 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 item_round_trip() {
|
||||
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new("hello".into()),
|
||||
}));
|
||||
let bytes = encrypt_item(&item, &key()).unwrap();
|
||||
let decoded = decrypt_item(&bytes, &key()).unwrap();
|
||||
assert_eq!(decoded.title, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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()));
|
||||
fn manifest_round_trip() {
|
||||
let mut m = Manifest::new();
|
||||
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
m.upsert(&item);
|
||||
let bytes = encrypt_manifest(&m, &key()).unwrap();
|
||||
let decoded = decrypt_manifest(&bytes, &key()).unwrap();
|
||||
assert_eq!(decoded.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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());
|
||||
fn settings_round_trip() {
|
||||
let s = VaultSettings::default();
|
||||
let bytes = encrypt_settings(&s, &key()).unwrap();
|
||||
let decoded = decrypt_settings(&bytes, &key()).unwrap();
|
||||
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
||||
s.attachment_caps.per_attachment_max_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
52
crates/relicario-core/tests/attachments.rs
Normal file
52
crates/relicario-core/tests/attachments.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement.
|
||||
|
||||
use relicario_core::{
|
||||
AttachmentId, RelicarioError,
|
||||
crypto::KdfParams,
|
||||
decrypt_attachment, derive_master_key, encrypt_attachment,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
|
||||
|
||||
fn make_key() -> Zeroizing<[u8; 32]> {
|
||||
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_round_trip_5kb() {
|
||||
let plaintext: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
|
||||
let key = make_key();
|
||||
let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap();
|
||||
assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext));
|
||||
|
||||
let dec = decrypt_attachment(&enc.bytes, &key).unwrap();
|
||||
assert_eq!(&*dec, &plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_plaintexts_yield_identical_aids() {
|
||||
let plaintext = b"hello world";
|
||||
let key = make_key();
|
||||
let a = encrypt_attachment(plaintext, &key, 1024).unwrap();
|
||||
let b = encrypt_attachment(plaintext, &key, 1024).unwrap();
|
||||
assert_eq!(a.id, b.id);
|
||||
// (Bytes will differ because nonce is random per-encryption — that's expected.)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_enforcement_at_exact_max() {
|
||||
let plaintext = vec![0u8; 1024];
|
||||
let key = make_key();
|
||||
// Exactly at max — should pass
|
||||
let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap();
|
||||
// One byte over — should fail
|
||||
let err = encrypt_attachment(&plaintext, &key, 1023);
|
||||
match err {
|
||||
Err(RelicarioError::AttachmentTooLarge { size, max }) => {
|
||||
assert_eq!(size, 1024);
|
||||
assert_eq!(max, 1023);
|
||||
}
|
||||
other => panic!("expected AttachmentTooLarge, got {other:?}"),
|
||||
}
|
||||
}
|
||||
63
crates/relicario-core/tests/field_history.rs
Normal file
63
crates/relicario-core/tests/field_history.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Field history end-to-end: capture on update, prune by retention policy,
|
||||
//! survive encrypt/decrypt round-trip.
|
||||
|
||||
use relicario_core::{
|
||||
Field, FieldValue, HistoryRetention, Item, ItemCore, Section,
|
||||
crypto::KdfParams,
|
||||
derive_master_key, decrypt_item, encrypt_item,
|
||||
};
|
||||
use relicario_core::item_types::LoginCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn key() -> Zeroizing<[u8; 32]> {
|
||||
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_field_history_captured_on_update() {
|
||||
let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap();
|
||||
|
||||
let hist = item.field_history.get(&fid).expect("history exists");
|
||||
assert_eq!(hist.len(), 3);
|
||||
assert_eq!(hist[0].value.as_str(), "v0");
|
||||
assert_eq!(hist[2].value.as_str(), "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_last_n_keeps_most_recent() {
|
||||
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
for i in 1..=10 {
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
|
||||
}
|
||||
item.prune_history(&HistoryRetention::LastN(3), 0);
|
||||
let hist = &item.field_history[&fid];
|
||||
assert_eq!(hist.len(), 3);
|
||||
// Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured)
|
||||
assert!(hist.last().unwrap().value.as_str().starts_with('v'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_survives_encrypt_decrypt() {
|
||||
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||
|
||||
let blob = encrypt_item(&item, &key()).unwrap();
|
||||
let decoded = decrypt_item(&blob, &key()).unwrap();
|
||||
|
||||
let hist = decoded.field_history.get(&fid).expect("history survived");
|
||||
assert_eq!(hist.len(), 1);
|
||||
assert_eq!(hist[0].value.as_str(), "v0");
|
||||
}
|
||||
54
crates/relicario-core/tests/format_v2.rs
Normal file
54
crates/relicario-core/tests/format_v2.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with
|
||||
//! UnsupportedFormatVersion, length-prefix construction guarantees domain
|
||||
//! separation.
|
||||
|
||||
use relicario_core::{
|
||||
RelicarioError,
|
||||
crypto::{KdfParams, VERSION_BYTE},
|
||||
decrypt, derive_master_key, encrypt,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
|
||||
|
||||
#[test]
|
||||
fn version_byte_is_2() {
|
||||
assert_eq!(VERSION_BYTE, 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_ciphertext_starts_with_0x02() {
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
// encrypt(key: &[u8; 32], plaintext: &[u8])
|
||||
let ct = encrypt(&key, b"hello").unwrap();
|
||||
assert_eq!(ct[0], 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_blob_is_rejected_with_unsupported_format_version() {
|
||||
// v1 layout: [0x01][24 nonce bytes][16 tag bytes]
|
||||
let mut blob = vec![0x01u8];
|
||||
blob.extend_from_slice(&[0u8; 24 + 16]);
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
// decrypt(key: &[u8; 32], data: &[u8])
|
||||
let err = decrypt(&key, &blob);
|
||||
match err {
|
||||
Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => {
|
||||
assert_eq!(found, 0x01);
|
||||
assert_eq!(expected, 0x02);
|
||||
}
|
||||
other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefix_distinguishes_concat_collisions() {
|
||||
let salt = [0u8; 32];
|
||||
let img = [0x44u8; 32];
|
||||
let p1 = b"abc";
|
||||
let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...)
|
||||
// could be made to collide. With length-prefix they cannot.
|
||||
let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap();
|
||||
let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap();
|
||||
assert_ne!(*k1, *k2);
|
||||
}
|
||||
89
crates/relicario-core/tests/generators.rs
Normal file
89
crates/relicario-core/tests/generators.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity,
|
||||
//! zxcvbn strength gate.
|
||||
//!
|
||||
//! # Note on length cap
|
||||
//!
|
||||
//! `generate_password` enforces `length <= 128`. The task originally specified
|
||||
//! `length: 10_000` in a single call, but that would error at runtime.
|
||||
//!
|
||||
//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with
|
||||
//! `length: 128` to gather 10,240 characters total, then aggregate per-class
|
||||
//! counts before asserting proportions. The ±5pp tolerance is unchanged because
|
||||
//! sample size is the same (~10k chars).
|
||||
|
||||
use relicario_core::{
|
||||
Capitalization, CharClasses, GeneratorRequest, SymbolCharset,
|
||||
generate_passphrase, generate_password, validate_passphrase_strength,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn random_password_class_balance_is_reasonable() {
|
||||
// Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics.
|
||||
// (generate_password caps at length 128, so we cannot do a single 10,000-char call.)
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 128,
|
||||
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
|
||||
let mut lower = 0usize;
|
||||
let mut upper = 0usize;
|
||||
let mut digits = 0usize;
|
||||
let mut total = 0usize;
|
||||
|
||||
for _ in 0..80 {
|
||||
let pw = generate_password(&req).unwrap();
|
||||
lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count();
|
||||
upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count();
|
||||
digits += pw.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
total += pw.len();
|
||||
}
|
||||
let symbols = total - lower - upper - digits;
|
||||
|
||||
// Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74
|
||||
// Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2%
|
||||
// Allow ±5pp slop.
|
||||
let t = total as f64;
|
||||
let assert_pct = |label: &str, actual: usize, expected_pct: f64| {
|
||||
let pct = (actual as f64) / t * 100.0;
|
||||
assert!(
|
||||
(pct - expected_pct).abs() < 5.0,
|
||||
"{label}: actual {pct:.1}% vs expected {expected_pct:.1}%"
|
||||
);
|
||||
};
|
||||
assert_pct("lower", lower, 26.0 / 74.0 * 100.0);
|
||||
assert_pct("upper", upper, 26.0 / 74.0 * 100.0);
|
||||
assert_pct("digits", digits, 10.0 / 74.0 * 100.0);
|
||||
assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_5_word_passphrase_passes_zxcvbn_gate() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 5,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_weak_passphrases_fail_gate() {
|
||||
for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] {
|
||||
assert!(
|
||||
validate_passphrase_strength(weak).is_err(),
|
||||
"expected '{weak}' to fail gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_passwords_are_unique_across_calls() {
|
||||
let req = GeneratorRequest::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
let pw = generate_password(&req).unwrap();
|
||||
assert!(seen.insert(pw.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -1,153 +1,111 @@
|
||||
use relicario_core::{
|
||||
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
|
||||
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
|
||||
};
|
||||
use rand::RngCore;
|
||||
//! End-to-end integration tests for the typed-item core.
|
||||
|
||||
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
|
||||
}
|
||||
use relicario_core::{
|
||||
crypto::KdfParams,
|
||||
derive_master_key, encrypt_item, decrypt_item,
|
||||
encrypt_manifest, decrypt_manifest,
|
||||
encrypt_settings, decrypt_settings,
|
||||
Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings,
|
||||
};
|
||||
use relicario_core::item_types::{LoginCore, SecureNoteCore};
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn fast_params() -> KdfParams {
|
||||
KdfParams {
|
||||
argon2_m: 256,
|
||||
argon2_t: 1,
|
||||
argon2_p: 1,
|
||||
}
|
||||
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);
|
||||
fn full_workflow_login_and_note() {
|
||||
let salt = [0xAAu8; 32];
|
||||
let img = [0xBBu8; 32];
|
||||
let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap();
|
||||
|
||||
// 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, ¶ms).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, ¶ms).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, ¶ms).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 settings = VaultSettings::default();
|
||||
|
||||
let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap();
|
||||
let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap();
|
||||
// Add a Login
|
||||
let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: Some(Url::parse("https://github.com").unwrap()),
|
||||
totp: None,
|
||||
}));
|
||||
manifest.upsert(&login);
|
||||
let login_blob = encrypt_item(&login, &key).unwrap();
|
||||
|
||||
assert_eq!(manifest_dec.version, 1);
|
||||
assert!(manifest_dec.entries.contains_key(&entry_id));
|
||||
assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub");
|
||||
// Add a SecureNote
|
||||
let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new("recovery codes go here".into()),
|
||||
}));
|
||||
manifest.upsert(¬e);
|
||||
let note_blob = encrypt_item(¬e, &key).unwrap();
|
||||
|
||||
// Encrypt manifest + settings
|
||||
let manifest_blob = encrypt_manifest(&manifest, &key).unwrap();
|
||||
let settings_blob = encrypt_settings(&settings, &key).unwrap();
|
||||
|
||||
// Decrypt + verify
|
||||
let m = decrypt_manifest(&manifest_blob, &key).unwrap();
|
||||
assert_eq!(m.items.len(), 2);
|
||||
|
||||
let l: Item = decrypt_item(&login_blob, &key).unwrap();
|
||||
let n: Item = decrypt_item(¬e_blob, &key).unwrap();
|
||||
let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap();
|
||||
|
||||
assert_eq!(l.title, "GitHub");
|
||||
assert_eq!(n.title, "recovery");
|
||||
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_factor_independence() {
|
||||
let mut salt = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
let params = fast_params();
|
||||
// Same passphrase, different image_secret → different keys.
|
||||
let salt = [0u8; 32];
|
||||
let img_a = [0x01u8; 32];
|
||||
let img_b = [0x02u8; 32];
|
||||
|
||||
let passphrase_a = b"passphrase-alpha";
|
||||
let passphrase_b = b"passphrase-bravo";
|
||||
let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap();
|
||||
let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap();
|
||||
assert_ne!(*key_a, *key_b);
|
||||
|
||||
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, ¶ms).unwrap();
|
||||
|
||||
// 2. (passphrase_B, image_A) -> different from #1
|
||||
let key_ba = derive_master_key(passphrase_b, &image_secret_a, &salt, ¶ms).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, ¶ms).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, ¶ms).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");
|
||||
// Different passphrase, same image_secret → different keys.
|
||||
let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap();
|
||||
assert_ne!(*key_a, *key_c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_history_persists_through_round_trip() {
|
||||
let salt = [0u8; 32];
|
||||
let img = [0u8; 32];
|
||||
let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap();
|
||||
|
||||
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
|
||||
|
||||
let blob = encrypt_item(&item, &key).unwrap();
|
||||
let decoded = decrypt_item(&blob, &key).unwrap();
|
||||
let hist = decoded.field_history.get(&fid).unwrap();
|
||||
assert_eq!(hist.len(), 2);
|
||||
assert_eq!(hist[0].value.as_str(), "v0");
|
||||
assert_eq!(hist[1].value.as_str(), "v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_with_opaque_decrypt() {
|
||||
use relicario_core::RelicarioError;
|
||||
|
||||
let salt = [0u8; 32];
|
||||
let img = [0u8; 32];
|
||||
let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap();
|
||||
let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap();
|
||||
|
||||
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
let blob = encrypt_item(&item, &right).unwrap();
|
||||
let err = decrypt_item(&blob, &wrong);
|
||||
assert!(matches!(err, Err(RelicarioError::Decrypt)));
|
||||
}
|
||||
|
||||
@@ -10,11 +10,10 @@ crate-type = ["cdylib", "rlib"]
|
||||
[dependencies]
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
serde_json = "1"
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
data-encoding = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
zeroize = "1"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,364 +1,282 @@
|
||||
//! WASM bindings for the relicario password manager.
|
||||
//! WASM bindings for relicario.
|
||||
//!
|
||||
//! This crate wraps [`relicario_core`] for use in a Chrome MV3 browser extension via
|
||||
//! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from
|
||||
//! JavaScript after loading the compiled `.wasm` module.
|
||||
//!
|
||||
//! All crypto operations run entirely in the browser -- the extension never sends
|
||||
//! secrets to any server. The TOTP function lets the extension generate live 6-digit
|
||||
//! authenticator codes without a separate authenticator app.
|
||||
//!
|
||||
//! ## Design notes
|
||||
//!
|
||||
//! - Functions accept and return `Vec<u8>`, `&[u8]`, and `String` -- wasm-bindgen
|
||||
//! handles the JS ↔ Rust marshalling automatically (typed arrays for bytes, strings
|
||||
//! for JSON).
|
||||
//! - Errors are mapped to `JsValue` strings so they surface as thrown exceptions in JS.
|
||||
//! - `generate_password` and `generate_entry_id` use `js_sys::Math::random()` because
|
||||
//! `OsRng`/`getrandom` requires special WASM configuration. `Math.random()` is
|
||||
//! sufficient for these non-security-critical operations (password character selection
|
||||
//! and identifier generation).
|
||||
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
|
||||
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
|
||||
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
||||
|
||||
mod session;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use relicario_core::crypto::{self, KdfParams};
|
||||
use relicario_core::entry::Entry;
|
||||
use relicario_core::vault;
|
||||
use relicario_core::imgsecret;
|
||||
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
|
||||
/// Derive a 256-bit master key from a passphrase, image secret, salt, and KDF parameters.
|
||||
///
|
||||
/// The `params_json` argument is a JSON object with fields `argon2_m`, `argon2_t`,
|
||||
/// and `argon2_p` (matching [`KdfParams`]). Example:
|
||||
///
|
||||
/// ```json
|
||||
/// {"argon2_m": 65536, "argon2_t": 3, "argon2_p": 4}
|
||||
/// ```
|
||||
///
|
||||
/// Returns a 32-byte `Uint8Array` in JavaScript.
|
||||
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
|
||||
#[wasm_bindgen]
|
||||
pub fn derive_master_key(
|
||||
pub struct SessionHandle(u32);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl SessionHandle {
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn value(&self) -> u32 { self.0 }
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn unlock(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8],
|
||||
image_bytes: &[u8],
|
||||
salt: &[u8],
|
||||
params_json: &str,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
let params: KdfParams =
|
||||
serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
let image_secret: &[u8; 32] = image_secret
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
|
||||
let salt: &[u8; 32] = salt
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;
|
||||
|
||||
let key = crypto::derive_master_key(passphrase.as_bytes(), image_secret, salt, ¶ms)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
Ok(key.to_vec())
|
||||
) -> Result<SessionHandle, JsError> {
|
||||
let params: KdfParams = serde_json::from_str(params_json)
|
||||
.map_err(|e| JsError::new(&format!("params: {e}")))?;
|
||||
let image_secret = imgsecret::extract(image_bytes)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let salt_arr: &[u8; 32] = salt.try_into()
|
||||
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let handle = session::insert(master_key);
|
||||
Ok(SessionHandle(handle))
|
||||
}
|
||||
|
||||
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
|
||||
///
|
||||
/// Returns the ciphertext as a `Uint8Array` in the format:
|
||||
/// `version(1) || nonce(24) || ciphertext+tag`.
|
||||
#[wasm_bindgen]
|
||||
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let key: &[u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
||||
crypto::encrypt(key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
pub fn lock(handle: &SessionHandle) -> bool {
|
||||
session::remove(handle.0)
|
||||
}
|
||||
|
||||
// Subsequent wasm_bindgen fns added in Tasks 19-21.
|
||||
|
||||
use serde_wasm_bindgen::Serializer;
|
||||
use relicario_core::{
|
||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||
encrypt_item, encrypt_manifest, encrypt_settings,
|
||||
Item, Manifest, VaultSettings,
|
||||
};
|
||||
|
||||
fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
|
||||
if session::with(handle.0, |_| ()).is_some() { Ok(()) }
|
||||
else { Err(JsError::new("invalid or locked session handle")) }
|
||||
}
|
||||
|
||||
fn js_value_for<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
|
||||
let ser = Serializer::new().serialize_maps_as_objects(true);
|
||||
v.serialize(&ser).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Decrypt a ciphertext blob produced by [`encrypt`], returning the original plaintext.
|
||||
///
|
||||
/// Returns the plaintext as a `Uint8Array`. Throws if the key is wrong or the data
|
||||
/// has been tampered with.
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let key: &[u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
||||
crypto::decrypt(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
|
||||
need_key(handle)?;
|
||||
let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
js_value_for(&out)
|
||||
}
|
||||
|
||||
/// Extract the 32-byte steganographic secret from a JPEG image.
|
||||
///
|
||||
/// Returns a 32-byte `Uint8Array` containing the embedded secret.
|
||||
/// Throws if the image is not a valid JPEG or the secret cannot be recovered.
|
||||
#[wasm_bindgen]
|
||||
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let secret =
|
||||
imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
Ok(secret.to_vec())
|
||||
pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
|
||||
need_key(handle)?;
|
||||
let m: Manifest = serde_json::from_str(manifest_json)
|
||||
.map_err(|e| JsError::new(&format!("manifest json: {e}")))?;
|
||||
session::with(handle.0, |k| encrypt_manifest(&m, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Embed a 256-bit secret into a carrier JPEG image.
|
||||
#[wasm_bindgen]
|
||||
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let secret: [u8; 32] = secret
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
|
||||
relicario_core::imgsecret::embed(carrier_jpeg, &secret)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
|
||||
need_key(handle)?;
|
||||
let out = session::with(handle.0, |k| decrypt_item(encrypted, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
js_value_for(&out)
|
||||
}
|
||||
|
||||
/// Encrypt an [`Entry`] (given as a JSON string) under the master key.
|
||||
///
|
||||
/// The `entry_json` must deserialize into an [`Entry`] struct. Returns the
|
||||
/// ciphertext as a `Uint8Array`.
|
||||
#[wasm_bindgen]
|
||||
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let key: &[u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
||||
let entry: Entry =
|
||||
serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
vault::encrypt_entry(key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError> {
|
||||
need_key(handle)?;
|
||||
let item: Item = serde_json::from_str(item_json)
|
||||
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
|
||||
session::with(handle.0, |k| encrypt_item(&item, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Decrypt an entry ciphertext blob and return the entry as a JSON string.
|
||||
///
|
||||
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
|
||||
let key: &[u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
||||
let entry =
|
||||
vault::decrypt_entry(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
|
||||
need_key(handle)?;
|
||||
let out = session::with(handle.0, |k| decrypt_settings(encrypted, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
js_value_for(&out)
|
||||
}
|
||||
|
||||
/// Encrypt a [`Manifest`] (given as a JSON string) under the master key.
|
||||
///
|
||||
/// Returns the ciphertext as a `Uint8Array`.
|
||||
#[wasm_bindgen]
|
||||
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
|
||||
let key: &[u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
||||
let manifest: relicario_core::entry::Manifest =
|
||||
serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError> {
|
||||
need_key(handle)?;
|
||||
let s: VaultSettings = serde_json::from_str(settings_json)
|
||||
.map_err(|e| JsError::new(&format!("settings json: {e}")))?;
|
||||
session::with(handle.0, |k| encrypt_settings(&s, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Decrypt a manifest ciphertext blob and return the manifest as a JSON string.
|
||||
///
|
||||
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
|
||||
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
|
||||
|
||||
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
|
||||
let key: &[u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
|
||||
let manifest = vault::decrypt_manifest(key, ciphertext)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
pub struct EncryptedAttachment {
|
||||
aid: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Generate a 6-digit TOTP code per RFC 6238.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `secret_base32`: the shared secret encoded in base32 (with or without padding).
|
||||
/// - `timestamp_secs`: the current Unix timestamp in seconds.
|
||||
///
|
||||
/// # Algorithm
|
||||
///
|
||||
/// 1. Decode the base32 secret.
|
||||
/// 2. Compute the time step: `T = timestamp_secs / 30`.
|
||||
/// 3. Compute `HMAC-SHA1(secret, T as big-endian u64)`.
|
||||
/// 4. Dynamic truncation: extract a 4-byte segment from the HMAC output at an
|
||||
/// offset determined by the last nibble.
|
||||
/// 5. Mask the high bit, take modulo 10^6, and zero-pad to 6 digits.
|
||||
///
|
||||
/// Returns a 6-character string like `"287082"`.
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
|
||||
generate_totp_inner(secret_base32, timestamp_secs)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
impl EncryptedAttachment {
|
||||
#[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
|
||||
#[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
|
||||
}
|
||||
|
||||
/// Inner TOTP implementation that returns a standard Result for testability
|
||||
/// (avoids depending on JsValue in native tests).
|
||||
fn generate_totp_inner(
|
||||
secret_base32: &str,
|
||||
timestamp_secs: u64,
|
||||
) -> std::result::Result<String, String> {
|
||||
// Normalize: strip whitespace, uppercase, remove padding for lenient decode,
|
||||
// then re-pad to a multiple of 8 for strict base32.
|
||||
let cleaned: String = secret_base32
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
.trim_end_matches('=')
|
||||
.to_string();
|
||||
|
||||
// Re-pad to a multiple of 8 characters (base32 requirement).
|
||||
let padded = {
|
||||
let remainder = cleaned.len() % 8;
|
||||
if remainder == 0 {
|
||||
cleaned
|
||||
} else {
|
||||
let pad_count = 8 - remainder;
|
||||
format!("{}{}", cleaned, "=".repeat(pad_count))
|
||||
}
|
||||
};
|
||||
|
||||
let secret = data_encoding::BASE32
|
||||
.decode(padded.as_bytes())
|
||||
.map_err(|e| format!("invalid base32 secret: {}", e))?;
|
||||
|
||||
// Time step: T = floor(timestamp / 30)
|
||||
let time_step = timestamp_secs / 30;
|
||||
|
||||
// HMAC-SHA1(secret, time_step as big-endian u64)
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
let mut mac =
|
||||
HmacSha1::new_from_slice(&secret).map_err(|e| format!("HMAC init failed: {}", e))?;
|
||||
mac.update(&time_step.to_be_bytes());
|
||||
let result = mac.finalize().into_bytes();
|
||||
|
||||
// Dynamic truncation per RFC 4226 section 5.4
|
||||
let offset = (result[19] & 0x0F) as usize;
|
||||
let code = ((result[offset] as u32 & 0x7F) << 24)
|
||||
| ((result[offset + 1] as u32) << 16)
|
||||
| ((result[offset + 2] as u32) << 8)
|
||||
| (result[offset + 3] as u32);
|
||||
|
||||
// 6-digit code, zero-padded
|
||||
Ok(format!("{:06}", code % 1_000_000))
|
||||
}
|
||||
|
||||
/// Generate a random password of the given length.
|
||||
///
|
||||
/// Uses `js_sys::Math::random()` for randomness (not cryptographically secure,
|
||||
/// but sufficient for password character selection). The character set includes
|
||||
/// uppercase, lowercase, digits, and common symbols.
|
||||
///
|
||||
/// This function is only available in WASM -- it will panic in native builds
|
||||
/// because `js_sys::Math::random()` requires a JS runtime.
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_password(length: u32) -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
|
||||
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
let idx = (js_sys::Math::random() * CHARSET.len() as f64) as usize;
|
||||
CHARSET[idx % CHARSET.len()] as char
|
||||
})
|
||||
.collect()
|
||||
pub fn attachment_encrypt(
|
||||
handle: &SessionHandle,
|
||||
plaintext: &[u8],
|
||||
max_bytes: u64,
|
||||
) -> Result<EncryptedAttachment, JsError> {
|
||||
need_key(handle)?;
|
||||
let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes })
|
||||
}
|
||||
|
||||
/// Generate a random 8-character hex string for use as an entry ID.
|
||||
///
|
||||
/// Uses `js_sys::Math::random()` for randomness. Entry IDs are not
|
||||
/// security-sensitive -- they are just opaque identifiers.
|
||||
///
|
||||
/// This function is only available in WASM -- it will panic in native builds
|
||||
/// because `js_sys::Math::random()` requires a JS runtime.
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_entry_id() -> String {
|
||||
(0..4)
|
||||
.map(|_| {
|
||||
let byte = (js_sys::Math::random() * 256.0) as u8;
|
||||
format!("{:02x}", byte)
|
||||
})
|
||||
.collect()
|
||||
pub fn attachment_decrypt(
|
||||
handle: &SessionHandle,
|
||||
encrypted: &[u8],
|
||||
) -> Result<Vec<u8>, JsError> {
|
||||
need_key(handle)?;
|
||||
let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(plain.to_vec())
|
||||
}
|
||||
|
||||
#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() }
|
||||
#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() }
|
||||
|
||||
use relicario_core::{
|
||||
generate_passphrase as core_generate_passphrase,
|
||||
generate_password as core_generate_password,
|
||||
rate_passphrase as core_rate_passphrase,
|
||||
GeneratorRequest,
|
||||
};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_password(request_json: &str) -> Result<String, JsError> {
|
||||
let req: GeneratorRequest = serde_json::from_str(request_json)
|
||||
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
|
||||
let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(out.as_str().to_owned())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_passphrase(request_json: &str) -> Result<String, JsError> {
|
||||
let req: GeneratorRequest = serde_json::from_str(request_json)
|
||||
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
|
||||
let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(out.as_str().to_owned())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
|
||||
let est = core_rate_passphrase(p);
|
||||
js_value_for(&serde_json::json!({
|
||||
"score": est.score,
|
||||
"guesses_log10": est.guesses_log10,
|
||||
}))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||
let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(s.to_vec())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||
let s: &[u8; 32] = secret.try_into()
|
||||
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
|
||||
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
use relicario_core::item_types::{TotpConfig, compute_totp_code};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct TotpCode {
|
||||
code: String,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl TotpCode {
|
||||
#[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() }
|
||||
#[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at }
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn totp_compute(
|
||||
config_json: &str,
|
||||
now_unix_seconds: u64,
|
||||
) -> Result<TotpCode, JsError> {
|
||||
let cfg: TotpConfig = serde_json::from_str(config_json)
|
||||
.map_err(|e| JsError::new(&format!("totp config: {e}")))?;
|
||||
let code = compute_totp_code(&cfg, now_unix_seconds)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let period = cfg.period_seconds as u64;
|
||||
let expires_at = ((now_unix_seconds / period) + 1) * period;
|
||||
Ok(TotpCode { code, expires_at })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[test]
|
||||
fn totp_rfc6238_test_vector() {
|
||||
// secret = "12345678901234567890" ASCII, time = 59, expected = "287082"
|
||||
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
|
||||
let result = generate_totp_inner(&secret_b32, 59).unwrap();
|
||||
assert_eq!(result, "287082");
|
||||
fn insert_then_remove_clears_entry() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x11u8; 32]));
|
||||
assert_ne!(h, 0);
|
||||
assert!(session::remove(h));
|
||||
assert!(!session::remove(h)); // second remove false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_rfc6238_test_vector_2() {
|
||||
// time = 1111111109, expected = "081804"
|
||||
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
|
||||
let result = generate_totp_inner(&secret_b32, 1111111109).unwrap();
|
||||
assert_eq!(result, "081804");
|
||||
fn with_yields_key_only_while_session_lives() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x22u8; 32]));
|
||||
let byte = session::with(h, |k| k[0]);
|
||||
assert_eq!(byte, Some(0x22));
|
||||
session::remove(h);
|
||||
let byte = session::with(h, |k| k[0]);
|
||||
assert_eq!(byte, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_rfc6238_test_vector_3() {
|
||||
// time = 1234567890, expected = "005924"
|
||||
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
|
||||
let result = generate_totp_inner(&secret_b32, 1234567890).unwrap();
|
||||
assert_eq!(result, "005924");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_invalid_base32_fails() {
|
||||
let result = generate_totp_inner("not-valid-base32!!!", 1000);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_key_via_wasm_wrapper() {
|
||||
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
|
||||
let key =
|
||||
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
let key2 =
|
||||
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_via_wasm_wrapper() {
|
||||
let key = [0xABu8; 32];
|
||||
let ciphertext = encrypt(b"hello wasm", &key).unwrap();
|
||||
let decrypted = decrypt(&ciphertext, &key).unwrap();
|
||||
assert_eq!(decrypted, b"hello wasm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_then_extract_round_trip() {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::{ImageBuffer, ImageEncoder, Rgb};
|
||||
|
||||
let img = ImageBuffer::from_fn(400, 300, |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 jpeg_buf = Vec::new();
|
||||
let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92);
|
||||
encoder.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8).unwrap();
|
||||
|
||||
let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
|
||||
0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8];
|
||||
|
||||
let stego = embed_image_secret(&jpeg_buf, &secret).unwrap();
|
||||
let extracted = extract_image_secret(&stego).unwrap();
|
||||
assert_eq!(extracted, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_entry_decrypt_entry_round_trip() {
|
||||
let key = [0xABu8; 32];
|
||||
let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
|
||||
let ciphertext = encrypt_entry(entry_json, &key).unwrap();
|
||||
let result = decrypt_entry(&ciphertext, &key).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["name"], "Test");
|
||||
assert_eq!(parsed["password"], "secret");
|
||||
fn manifest_round_trip_via_handle() {
|
||||
use relicario_core::{Manifest, decrypt_manifest};
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
let key = Zeroizing::new([0x55u8; 32]);
|
||||
let empty = Manifest::new();
|
||||
let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
// Decrypt via core directly (avoids js-sys on native).
|
||||
let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
|
||||
assert_eq!(parsed.items.len(), 0);
|
||||
// Random nonces mean two encryptions of the same plaintext differ.
|
||||
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||
assert_ne!(bytes, bytes2, "nonces must differ");
|
||||
}
|
||||
}
|
||||
|
||||
41
crates/relicario-wasm/src/session.rs
Normal file
41
crates/relicario-wasm/src/session.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Opaque session-handle bridge. The master key never leaves WASM linear
|
||||
//! memory; JS receives only a u32 handle that it passes back on every
|
||||
//! subsequent call.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
thread_local! {
|
||||
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
|
||||
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
||||
}
|
||||
|
||||
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
let handle = NEXT_HANDLE.with(|n| {
|
||||
let mut n = n.borrow_mut();
|
||||
let h = *n;
|
||||
*n = n.wrapping_add(1);
|
||||
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||
h
|
||||
});
|
||||
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
||||
handle
|
||||
}
|
||||
|
||||
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||
{
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
|
||||
}
|
||||
|
||||
pub fn remove(handle: u32) -> bool {
|
||||
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
|
||||
}
|
||||
|
||||
/// For tests only — empty the table and wipe all sessions.
|
||||
#[cfg(test)]
|
||||
pub fn clear() {
|
||||
SESSIONS.with(|s| s.borrow_mut().clear());
|
||||
}
|
||||
79
extension/src/wasm.d.ts
vendored
79
extension/src/wasm.d.ts
vendored
@@ -1,25 +1,58 @@
|
||||
/// Type declarations for the relicario WASM module produced by wasm-pack.
|
||||
// Thin TypeScript declarations for the relicario-wasm bindings.
|
||||
// These are hand-written to mirror the #[wasm_bindgen] signatures in
|
||||
// crates/relicario-wasm/src/lib.rs; keep them in sync manually.
|
||||
|
||||
// Ambient module declarations for the WASM glue code.
|
||||
// The module specifier must exactly match what's used in import statements.
|
||||
|
||||
declare module 'relicario-wasm' {
|
||||
export default function init(input?: string | URL): Promise<void>;
|
||||
export function derive_master_key(
|
||||
passphrase: string,
|
||||
image_secret: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
params_json: string,
|
||||
): Uint8Array;
|
||||
export function encrypt(plaintext: Uint8Array, key: Uint8Array): Uint8Array;
|
||||
export function decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array;
|
||||
export function extract_image_secret(jpeg_bytes: Uint8Array): Uint8Array;
|
||||
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
|
||||
export function encrypt_entry(entry_json: string, key: Uint8Array): Uint8Array;
|
||||
export function decrypt_entry(ciphertext: Uint8Array, key: Uint8Array): string;
|
||||
export function encrypt_manifest(manifest_json: string, key: Uint8Array): Uint8Array;
|
||||
export function decrypt_manifest(ciphertext: Uint8Array, key: Uint8Array): string;
|
||||
export function generate_totp(secret_base32: string, timestamp_secs: bigint): string;
|
||||
export function generate_password(length: number): string;
|
||||
export function generate_entry_id(): string;
|
||||
export class SessionHandle {
|
||||
readonly value: number;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
export class EncryptedAttachment {
|
||||
readonly aid: string;
|
||||
readonly bytes: Uint8Array;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
export class TotpCode {
|
||||
readonly code: string;
|
||||
readonly expires_at: number;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
export function unlock(
|
||||
passphrase: string,
|
||||
image_bytes: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
params_json: string,
|
||||
): SessionHandle;
|
||||
|
||||
export function lock(handle: SessionHandle): boolean;
|
||||
|
||||
export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
|
||||
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
|
||||
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;
|
||||
|
||||
export function attachment_encrypt(
|
||||
handle: SessionHandle,
|
||||
plaintext: Uint8Array,
|
||||
max_bytes: bigint,
|
||||
): EncryptedAttachment;
|
||||
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;
|
||||
|
||||
export function new_item_id(): string;
|
||||
export function new_field_id(): string;
|
||||
|
||||
export function generate_password(request_json: string): string;
|
||||
export function generate_passphrase(request_json: string): string;
|
||||
export function rate_passphrase(p: string): { score: number; guesses_log10: number };
|
||||
|
||||
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
|
||||
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
|
||||
|
||||
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
||||
|
||||
// Initializer (wasm-bindgen's default init function).
|
||||
export default function init(module_or_path?: unknown): Promise<void>;
|
||||
|
||||
BIN
manifest.enc
Normal file
BIN
manifest.enc
Normal file
Binary file not shown.
BIN
settings.enc
Normal file
BIN
settings.enc
Normal file
Binary file not shown.
Reference in New Issue
Block a user