Compare commits
108 Commits
519a6f0e36
...
plan-1c-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3c3893bb | ||
|
|
9139dd78a0 | ||
|
|
357455d979 | ||
|
|
69bb58c977 | ||
|
|
4341124d38 | ||
|
|
3238ef4dd4 | ||
|
|
f3b915a635 | ||
|
|
76bb61aa10 | ||
|
|
bc95b047a2 | ||
|
|
dc8097589e | ||
|
|
d090fc421e | ||
|
|
856ceb2d93 | ||
|
|
1d5ad5e59e | ||
|
|
eed11acba2 | ||
|
|
14397b33f0 | ||
|
|
8cc1e777be | ||
|
|
fbb64729ce | ||
|
|
2ff3ab1d7f | ||
|
|
0cef607859 | ||
|
|
3d2b021cb2 | ||
|
|
2d4dcb5f6b | ||
|
|
56ab58cbe9 | ||
|
|
be32ea13c6 | ||
|
|
533bfd5bea | ||
|
|
2fd6daad8e | ||
|
|
c0fba2a8dc | ||
|
|
20144e8e02 | ||
|
|
bd9dd206ac | ||
|
|
7781a51848 | ||
|
|
dc8afcb634 | ||
|
|
b4da5bffcf | ||
|
|
04c9503036 | ||
|
|
14aaac672c | ||
|
|
c03a492ee3 | ||
|
|
ad6d8af2f6 | ||
|
|
a1d733ddeb | ||
|
|
76f34bfcf5 | ||
|
|
e0c511e320 | ||
|
|
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 |
2
.gitignore
vendored
@@ -5,3 +5,5 @@ extension/node_modules/
|
||||
extension/dist/
|
||||
extension/dist-firefox/
|
||||
extension/wasm/
|
||||
reference.jpg
|
||||
ref.jpg
|
||||
|
||||
39
CLAUDE.md
@@ -10,8 +10,10 @@ relicario is a git-backed, self-hostable password manager with a Rust core. Two-
|
||||
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 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
|
||||
@@ -22,15 +24,26 @@ crates/
|
||||
│ ├── 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
|
||||
│ │ ├── 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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
3319
docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# relicario — Extension Plan 1C-α (Foundation) Design
|
||||
|
||||
First of three sub-plans that port the browser extension from the v1 single-`Entry` data model to the typed-item model landed in Plans 1A + 1B. 1C-α is the **foundation slice**: rebuild the WASM artifact, migrate shared types, rewrite the service worker against the opaque `SessionHandle` surface, split the message router with sender checks, wire the full security architecture from the typed-items spec, and achieve Login-parity on the new stack. Other six item types show "Coming in 1C-β" placeholders.
|
||||
|
||||
This spec references the broader design at `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — read that for the cryptographic envelope, data-model rationale, and threat model. This document is the extension-side implementation design for the first of the three 1C sub-plans.
|
||||
|
||||
## Plan 1C decomposition
|
||||
|
||||
| Sub-plan | Scope |
|
||||
|---|---|
|
||||
| **1C-α (this spec)** | WASM rebuild, shared types, service-worker rewrite, router split, security architecture, Login-parity popup, setup-wizard zxcvbn, Firefox parity |
|
||||
| 1C-β | Per-type forms for the other six types, sections + custom fields, full vault-settings UI, generator-request UI |
|
||||
| 1C-γ | Attachments (with `putBlob` Git-Data-API fallback), trash view, field-history view, device management |
|
||||
|
||||
Each sub-plan gets its own spec → plan → implementation cycle.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
Captured during brainstorming:
|
||||
|
||||
| Question | Decision | Why |
|
||||
|---|---|---|
|
||||
| How many sub-plans? | Three (α/β/γ) | Single plan is too large to review or execute without drift; three sub-plans give natural checkpoints |
|
||||
| What's "done" for 1C-α? | **Login-parity on the new stack** — all existing single-`Entry` flows re-expressed as `Item::Login`; other six types show "coming soon" | Validates the full pipeline end-to-end (item + manifest + git commit) before β's UI sprawl; keeps extension usable during β |
|
||||
| Firefox in scope for α? | Yes, concurrent with Chrome | Shared TS source; marginal extra cost; avoids mid-β surprises from silent rot |
|
||||
| Where do capture UX prefs + blacklist live? | `chrome.storage.local` (device-local) | TOFU origin-ack is a security posture (vault-level); capture prompt style is a UX preference that genuinely differs per device; blacklist churn pollutes git log |
|
||||
| zxcvbn strength gate in α or β? | **α** | Audit H3 (security); leaving a weak-passphrase window open during β is the same shape of mistake as leaving autofill origin-unbound during α |
|
||||
| Sequencing | **Bottom-up six-slice** (WASM artifact → types → SW vault/session → router → security → popup rewire + zxcvbn) | Matches Plan 1B's small-task cadence; gives the plan-executor clean checkpoints |
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- **WASM artifact** rebuilt from `relicario-wasm` crate (replacing stale `idfoto_wasm*` files).
|
||||
- **Shared TypeScript types / messages** migrated to the typed-item surface (`Item`, `ItemCore`, `Manifest` v2, `AttachmentRef`, plus the minimal `VaultSettings` subset needed for origin-ack).
|
||||
- **Service-worker rewrite**: `SessionHandle`-based `session.ts`, rewritten `vault.ts`, split `router/{index,popup-only,content-callable}.ts` with sender checks.
|
||||
- **Security architecture**:
|
||||
- WAR cleanup — `setup.html`, `setup.js`, wasm artifacts dropped.
|
||||
- Setup opened via `chrome.tabs.create`.
|
||||
- Origin-bound autofill (`sender.tab.url` only, hostname equality, top-frame only).
|
||||
- TOFU origin-ack via `VaultSettings.autofill_origin_acks`.
|
||||
- Popup captured-tab verification for `fill_credentials` (audit M5).
|
||||
- Closed Shadow DOM for all content-script UI.
|
||||
- `textContent`-only DOM construction; randomized-per-prompt element references; bounded page-derived strings.
|
||||
- **Login-parity popup**: view, add, edit, delete, autofill, capture for `Item::Login`. Other six types appear in the "New…" menu but open a "Coming in 1C-β" placeholder.
|
||||
- **Setup wizard**: zxcvbn strength meter + `score >= 3` gate (via new `rate_passphrase` message).
|
||||
- **Firefox build** re-verified with the new manifest + webpack config.
|
||||
|
||||
### Out (→ 1C-β / 1C-γ)
|
||||
|
||||
- Per-type forms for SecureNote / Identity / Card / Key / Document / Totp (β).
|
||||
- Sections + custom fields UI (β).
|
||||
- Full vault-settings view (retention policies, generator defaults, attachment caps) — α touches `settings.enc` only for origin-ack (β).
|
||||
- Attachments and `putBlob` Git-Data-API fallback — Login items fit Contents API (γ).
|
||||
- Trash view, field-history view (γ).
|
||||
- Device management UI — CLI already handles it (γ).
|
||||
- BIP39 / advanced generator-request UI — α uses a default `Random { length: 20, classes: lower+upper+digits+symbols, symbol_charset: SafeOnly }` for the "gen" button (β).
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
extension/src/service-worker/session.ts # SessionHandle lifecycle
|
||||
extension/src/service-worker/router/index.ts # single onMessage entry + sender dispatch
|
||||
extension/src/service-worker/router/popup-only.ts # popup-callable handlers
|
||||
extension/src/service-worker/router/content-callable.ts # content-script-callable handlers
|
||||
extension/src/service-worker/router/__tests__/router.test.ts # sender-check + origin-bound autofill tests
|
||||
extension/src/content/shadow.ts # closed Shadow DOM host helper
|
||||
extension/src/shared/base32.ts # base32 encode/decode for TOTP field parse
|
||||
```
|
||||
|
||||
### Rewritten
|
||||
|
||||
```
|
||||
extension/src/shared/types.ts # Item, ItemCore, FieldKind, VaultSettings, ManifestEntry v2
|
||||
extension/src/shared/messages.ts # PopupMessage + ContentMessage unions
|
||||
extension/src/service-worker/vault.ts # typed-item ops via SessionHandle
|
||||
extension/src/service-worker/index.ts # thin init + WASM load, delegates to router
|
||||
extension/src/content/capture.ts # closed Shadow DOM, textContent
|
||||
extension/src/content/icon.ts # closed Shadow DOM, textContent
|
||||
extension/src/popup/popup.ts # item dispatch, captured-tab snapshot on init
|
||||
extension/src/popup/components/entry-list.ts # → item-list.ts
|
||||
extension/src/popup/components/entry-detail.ts # → item-detail.ts (Login dispatcher + "coming soon")
|
||||
extension/src/popup/components/entry-form.ts # → item-form.ts (Login dispatcher + "coming soon")
|
||||
extension/src/popup/components/setup-wizard.ts # zxcvbn meter + gate
|
||||
extension/src/setup/setup.ts # zxcvbn meter + gate (mirror)
|
||||
extension/src/wasm.d.ts # mirror crates/relicario-wasm/src/lib.rs
|
||||
extension/manifest.json # WAR cleanup
|
||||
extension/manifest.firefox.json # WAR cleanup
|
||||
```
|
||||
|
||||
### Deleted
|
||||
|
||||
```
|
||||
extension/wasm/idfoto_wasm* # stale pre-rename artifact
|
||||
```
|
||||
|
||||
## WASM Artifact
|
||||
|
||||
`npm run build:wasm` already targets `../crates/relicario-wasm --out-dir ../../extension/wasm`. Running it produces `relicario_wasm.js`, `relicario_wasm_bg.wasm`, and `relicario_wasm.d.ts` in the output directory. Delete the stale `idfoto_wasm*` files. Both webpack configs already import from `../../wasm/relicario_wasm.js` — no config edits required.
|
||||
|
||||
`wasm.d.ts` currently mirrors the Plan 1B surface; skim it for drift against `crates/relicario-wasm/src/lib.rs` after the rebuild (particularly the `attachment_encrypt` third argument `max_bytes: bigint`).
|
||||
|
||||
## Shared Types (`shared/types.ts`)
|
||||
|
||||
Mirror the Rust core verbatim through serde serialization. The Rust-side shapes use a mix of `snake_case`, internal tagging (`#[serde(tag = "type")]` for `ItemCore`, `tag = "kind"` for `GeneratorRequest`), adjacent tagging (`tag = "kind", content = "value"` for `FieldValue`, `TrashRetention`, `HistoryRetention`, `SymbolCharset`), and default external tagging (`TotpKind`). The TS types must match exactly:
|
||||
|
||||
```ts
|
||||
export type ItemId = string; // 16-char hex
|
||||
export type FieldId = string;
|
||||
export type AttachmentId = string;
|
||||
|
||||
// snake_case strings, matches serde rename_all = "snake_case"
|
||||
export type ItemType = 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';
|
||||
|
||||
export interface Item {
|
||||
id: ItemId;
|
||||
title: string;
|
||||
type: ItemType; // Rust's `r#type` serializes as `type`
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group?: string; // omitted when None (#[serde(skip_serializing_if)])
|
||||
notes?: string;
|
||||
created: number;
|
||||
modified: number;
|
||||
trashed_at?: number;
|
||||
core: ItemCore; // internally-tagged on `"type"` — see below
|
||||
sections: Section[];
|
||||
attachments: AttachmentRef[];
|
||||
field_history: Record<FieldId, FieldHistoryEntry[]>;
|
||||
}
|
||||
|
||||
// Internally-tagged: ItemCore variant's fields get merged with `"type"` discriminant.
|
||||
// Example wire format for Login: { "type": "login", "username": "...", ... }
|
||||
export type ItemCore =
|
||||
| ({ type: 'login' } & LoginCore)
|
||||
| ({ type: 'secure_note' } & SecureNoteCore)
|
||||
| ({ type: 'identity' } & IdentityCore)
|
||||
| ({ type: 'card' } & CardCore)
|
||||
| ({ type: 'key' } & KeyCore)
|
||||
| ({ type: 'document' } & DocumentCore)
|
||||
| ({ type: 'totp' } & TotpCore);
|
||||
|
||||
export interface LoginCore {
|
||||
username?: string;
|
||||
password?: string;
|
||||
url?: string; // Rust serializes `Url` as its string form
|
||||
totp?: TotpConfig;
|
||||
}
|
||||
|
||||
// TotpKind is externally-tagged (default for enums without #[serde(tag)]):
|
||||
// Totp → "totp" (unit variant serializes as bare string)
|
||||
// Hotp{counter}→ { "hotp": { "counter": 42 } }
|
||||
// Steam → "steam"
|
||||
export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };
|
||||
|
||||
export interface TotpConfig {
|
||||
secret: number[]; // Vec<u8> → JSON number array
|
||||
algorithm: 'sha1' | 'sha256' | 'sha512';
|
||||
digits: number;
|
||||
period_seconds: number;
|
||||
kind: TotpKind;
|
||||
}
|
||||
|
||||
// Populated minimally for α (structural shape only, no UI); β fills in:
|
||||
export interface SecureNoteCore { body: string; }
|
||||
export interface IdentityCore { /* ... */ }
|
||||
export interface CardCore { /* ... */ }
|
||||
export interface KeyCore { /* ... */ }
|
||||
export interface DocumentCore { filename: string; mime_type: string; primary_attachment: AttachmentId; }
|
||||
export interface TotpCore { config: TotpConfig; issuer: string | null; label: string | null; }
|
||||
|
||||
export interface Manifest {
|
||||
schema_version: number;
|
||||
items: Record<ItemId, ManifestEntry>;
|
||||
}
|
||||
|
||||
export interface ManifestEntry {
|
||||
id: ItemId;
|
||||
type: ItemType;
|
||||
title: string;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group: string | null;
|
||||
icon_hint: string | null;
|
||||
modified: number;
|
||||
trashed_at: number | null;
|
||||
attachment_summaries: AttachmentSummary[];
|
||||
}
|
||||
|
||||
export interface VaultSettings {
|
||||
trash_retention: unknown; // opaque in α; full shape in β
|
||||
field_history_retention: unknown;
|
||||
generator_defaults: unknown;
|
||||
attachment_caps: unknown;
|
||||
autofill_origin_acks: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface DeviceSettings { // chrome.storage.local shape (was RelicarioSettings)
|
||||
captureEnabled: boolean;
|
||||
captureStyle: 'bar' | 'toast';
|
||||
}
|
||||
|
||||
// GeneratorRequest is internally-tagged on "kind", struct variants:
|
||||
export type GeneratorRequest =
|
||||
| { kind: 'bip39'; word_count: number; separator: string; capitalization: Capitalization }
|
||||
| { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset };
|
||||
|
||||
export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
|
||||
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }
|
||||
|
||||
// SymbolCharset is adjacently-tagged { tag: "kind", content: "value" }:
|
||||
export type SymbolCharset =
|
||||
| { kind: 'safe_only' }
|
||||
| { kind: 'extended' }
|
||||
| { kind: 'custom'; value: string };
|
||||
|
||||
// TrashRetention / HistoryRetention use the same adjacent tagging:
|
||||
export type TrashRetention =
|
||||
| { kind: 'forever' }
|
||||
| { kind: 'days'; value: number };
|
||||
|
||||
export type HistoryRetention =
|
||||
| { kind: 'forever' }
|
||||
| { kind: 'last_n'; value: number }
|
||||
| { kind: 'days'; value: number };
|
||||
|
||||
// FieldValue adjacently-tagged { tag: "kind", content: "value" }, snake_case:
|
||||
export type FieldValue =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'multiline'; value: string }
|
||||
| { kind: 'password'; value: string }
|
||||
| { kind: 'concealed'; value: string }
|
||||
| { kind: 'url'; value: string } // Url → string
|
||||
| { kind: 'email'; value: string }
|
||||
| { kind: 'phone'; value: string }
|
||||
| { kind: 'date'; value: string } // chrono NaiveDate → "YYYY-MM-DD"
|
||||
| { kind: 'month_year'; value: { month: number; year: number } }
|
||||
| { kind: 'totp'; value: TotpConfig }
|
||||
| { kind: 'reference'; value: AttachmentId };
|
||||
|
||||
export type FieldKind =
|
||||
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
|
||||
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
|
||||
```
|
||||
|
||||
Plus `Section`, `Field`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.
|
||||
|
||||
The serialization shapes above are verified in slice 3's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality against a Rust-side item written by the CLI.
|
||||
|
||||
## Messages (`shared/messages.ts`)
|
||||
|
||||
Two unions, so TypeScript itself enforces the router boundary:
|
||||
|
||||
```ts
|
||||
export type PopupMessage =
|
||||
| { type: 'is_unlocked' }
|
||||
| { type: 'unlock'; passphrase: string }
|
||||
| { type: 'lock' }
|
||||
| { type: 'list_items'; group?: string }
|
||||
| { type: 'get_item'; id: ItemId }
|
||||
| { type: 'add_item'; item: Item }
|
||||
| { type: 'update_item'; id: ItemId; item: Item }
|
||||
| { type: 'delete_item'; id: ItemId } // soft-delete (sets trashed_at)
|
||||
| { type: 'get_totp'; id: ItemId }
|
||||
| { type: 'sync' }
|
||||
| { type: 'get_setup_state' }
|
||||
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
||||
| { type: 'rate_passphrase'; passphrase: string }
|
||||
| { type: 'generate_password'; request: GeneratorRequest }
|
||||
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
|
||||
| { type: 'ack_autofill_origin'; hostname: string }
|
||||
| { type: 'get_settings' } // DeviceSettings (local)
|
||||
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string };
|
||||
|
||||
export type ContentMessage =
|
||||
| { type: 'get_autofill_candidates' } // url comes from sender.tab.url
|
||||
| { type: 'get_credentials'; id: ItemId } // origin-checked against sender.tab.url
|
||||
| { type: 'check_credential'; username: string; password: string } // url from sender
|
||||
| { type: 'blacklist_site' }; // hostname from sender
|
||||
|
||||
export type Request = PopupMessage | ContentMessage;
|
||||
```
|
||||
|
||||
Deliberate omissions: `get_autofill_candidates`, `check_credential`, `blacklist_site` no longer carry a `url` — the SW derives it from `sender.tab.url`. `fill_credentials` now takes an item id + captured tab state instead of raw credentials, so the SW can re-verify origin on the forwarding hop.
|
||||
|
||||
## Service Worker
|
||||
|
||||
### `service-worker/session.ts`
|
||||
|
||||
Single module-scope "current" `SessionHandle` (single-vault assumption per the broader spec).
|
||||
|
||||
```ts
|
||||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||
|
||||
let current: SessionHandle | null = null;
|
||||
|
||||
export function setCurrent(h: SessionHandle): void { current = h; }
|
||||
export function getCurrent(): SessionHandle | null { return current; }
|
||||
export function clearCurrent(): void {
|
||||
if (!current) return;
|
||||
try { current.free(); } catch { /* already freed */ }
|
||||
current = null;
|
||||
}
|
||||
export function requireCurrent(): SessionHandle {
|
||||
if (!current) throw new Error('vault_locked');
|
||||
return current;
|
||||
}
|
||||
```
|
||||
|
||||
SW idle-suspend (Chrome) or explicit `lock` message clears the handle. Firefox's persistent background script retains it until explicit lock — consistent with the spec.
|
||||
|
||||
### `service-worker/vault.ts`
|
||||
|
||||
Public surface, all handle-keyed:
|
||||
|
||||
```ts
|
||||
fetchVaultMeta(git): Promise<{ salt: Uint8Array; paramsJson: string }>
|
||||
fetchAndDecryptManifest(git, handle): Promise<Manifest>
|
||||
encryptAndWriteManifest(git, handle, manifest, message): Promise<void>
|
||||
fetchAndDecryptItem(git, handle, id): Promise<Item>
|
||||
encryptAndWriteItem(git, handle, id, item, message): Promise<void>
|
||||
fetchAndDecryptSettings(git, handle): Promise<VaultSettings>
|
||||
encryptAndWriteSettings(git, handle, settings, message): Promise<void>
|
||||
listItems(manifest, filter?): Array<[ItemId, ManifestEntry]>
|
||||
searchItems(manifest, query): Array<[ItemId, ManifestEntry]>
|
||||
findByHostname(manifest, hostname): Array<[ItemId, ManifestEntry]> // hostname from caller
|
||||
```
|
||||
|
||||
No `masterKey: Uint8Array` anywhere in the module surface.
|
||||
|
||||
### `service-worker/router/index.ts`
|
||||
|
||||
Single `chrome.runtime.onMessage.addListener`. Sender predicates:
|
||||
|
||||
```ts
|
||||
const popupUrl = chrome.runtime.getURL('popup.html');
|
||||
const setupUrl = chrome.runtime.getURL('setup.html');
|
||||
const senderUrl = sender.url ?? '';
|
||||
|
||||
const isPopup = senderUrl === popupUrl;
|
||||
const isSetup = senderUrl.startsWith(setupUrl);
|
||||
const isContent = sender.tab !== undefined
|
||||
&& sender.frameId === 0
|
||||
&& sender.id === chrome.runtime.id;
|
||||
```
|
||||
|
||||
`POPUP_ONLY` capability set = every `PopupMessage` type. `CONTENT_CALLABLE` = every `ContentMessage` type. `save_setup` is the one exception: accepted from `isPopup || isSetup`.
|
||||
|
||||
Unauthorized sender → `{ ok: false, error: 'unauthorized_sender' }` synchronously. Unknown type → `{ ok: false, error: 'unknown_message_type' }`. The two handler files stay thin — pure `switch (msg.type)` with no sender logic (the router already verified).
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### WAR cleanup (audit C1)
|
||||
|
||||
Both manifests drop `setup.html`, `setup.js`, `relicario_wasm.js`, `relicario_wasm_bg.wasm` from `web_accessible_resources`. The WAR array becomes `[{ resources: ["styles.css"], matches: ["<all_urls>"] }]` if styles are still needed, else disappears.
|
||||
|
||||
The popup opens setup via:
|
||||
|
||||
```ts
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||
```
|
||||
|
||||
Own-origin extension tabs work without WAR. The SW loads WASM via `chrome.runtime.getURL(...)` from the extension origin — no WAR either.
|
||||
|
||||
### Sender dispatch (audit C1, C2)
|
||||
|
||||
Implemented in `router/index.ts` as described above. The `save_setup` exception is the one place this deviates from "pure set-membership" — it accepts either `isPopup` or `isSetup`.
|
||||
|
||||
### Origin-bound autofill (audit C4)
|
||||
|
||||
Content-callable handlers derive origin exclusively from `sender.tab.url`. Flow:
|
||||
|
||||
- **`get_autofill_candidates`**: parse `sender.tab.url` → hostname. Return manifest entries whose `LoginCore.url` hostname equals page hostname. Top-frame only (`sender.frameId === 0` already enforced at router).
|
||||
- **`get_credentials(id)`**: fetch item. Parse `LoginCore.url` hostname. Compare to `sender.tab.url` hostname. Mismatch → `{ ok: false, error: 'origin_mismatch' }`. No item data leaked on mismatch.
|
||||
- **`check_credential`**: same origin derivation; `username`/`password` compared against manifest + decrypted item.
|
||||
- **`blacklist_site`**: hostname derived from `sender.tab.url`.
|
||||
|
||||
### TOFU origin-ack
|
||||
|
||||
When `get_credentials` succeeds on an origin not present in `VaultSettings.autofill_origin_acks`:
|
||||
|
||||
1. SW returns `{ ok: true, data: { requires_ack: true, hostname } }` — no credentials.
|
||||
2. Content script surfaces a "confirm autofill" dialog inside its closed Shadow DOM (lightweight — just a re-use of the capture prompt layout).
|
||||
3. User clicks "Confirm in relicario" → focuses the extension popup (the content-script dialog shows a prompt to open the popup; `chrome.action.openPopup()` is Chrome-only and unreliable, so α uses an instructional "open relicario to confirm" message instead).
|
||||
4. User opens popup → popup detects a pending ack via `VaultSettings.autofill_origin_acks` diff, shows "Confirm autofill on `<hostname>`?" → user acks → popup sends `ack_autofill_origin { hostname }` (popup-only, writes `settings.enc`).
|
||||
5. Next autofill attempt on the same origin succeeds.
|
||||
|
||||
The ack is popup-only because it's a vault write; the content-script dialog is purely instructional. Full in-page ack UI (including a tighter retry loop) is β-polish territory.
|
||||
|
||||
### Popup captured-tab verification (audit M5)
|
||||
|
||||
On popup open:
|
||||
|
||||
```ts
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const captured = { tabId: tab.id!, url: tab.url ?? '' };
|
||||
```
|
||||
|
||||
Stashed on `PopupState`. `fill_credentials` messages carry `capturedTabId` + `capturedUrl`. SW handler:
|
||||
|
||||
1. Look up item by `id`.
|
||||
2. `chrome.tabs.get(capturedTabId)` — if gone or navigated, reject.
|
||||
3. Compare `new URL(tab.url).hostname` to `new URL(capturedUrl).hostname` — mismatch rejects.
|
||||
4. Compare captured hostname to `LoginCore.url`'s hostname — mismatch rejects.
|
||||
5. Forward via `chrome.tabs.sendMessage(capturedTabId, { type: 'fill_credentials', username, password })`.
|
||||
|
||||
Content-script fill listener stays as-is (native-setter trick is already correct).
|
||||
|
||||
### Closed Shadow DOM (audit C3)
|
||||
|
||||
`content/shadow.ts`:
|
||||
|
||||
```ts
|
||||
export function createShadowHost(): { host: HTMLElement; root: ShadowRoot; destroy: () => void } {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = host.attachShadow({ mode: 'closed' });
|
||||
return { host, root, destroy: () => host.remove() };
|
||||
}
|
||||
```
|
||||
|
||||
Used by `icon.ts` (per-password-field host for the icon + picker) and `capture.ts` (submit-prompt). Strict rules enforced by review + a lint rule (`no-restricted-syntax` against `MemberExpression[property.name=/^(innerHTML|outerHTML)$/]` inside `extension/src/content/`):
|
||||
|
||||
1. **No `innerHTML` / `insertAdjacentHTML` / `document.write`** anywhere in `content/`. All DOM via `createElement` + `textContent` + `appendChild`.
|
||||
2. **No stable element IDs or classes inside shadow trees**. Wire handlers via local references.
|
||||
3. **Page-derived strings bounded**: `findUsernameValue` result capped at 256 chars, control characters stripped via `replace(/\p{Cc}/gu, '')`, assigned only via `.textContent`.
|
||||
4. **Disposal**: removing the host element drops the shadow root, detaches handlers.
|
||||
|
||||
Styles inside the shadow tree via `style.setProperty(...)` calls, or a single `<style>` element whose text content is a static literal.
|
||||
|
||||
### JS-side passphrase hygiene
|
||||
|
||||
Best-effort only (JS strings are immutable). In the `unlock` handler: receive passphrase, pass directly to `wasm.unlock(...)`, then `req.passphrase = ''` and let the message object go out of scope. Never log passphrase content. WASM-side zeroization is the primary defense (already handled Rust-side).
|
||||
|
||||
## Popup
|
||||
|
||||
### Entry flow
|
||||
|
||||
`popup.ts` unchanged in shape. Init sequence:
|
||||
|
||||
1. `get_setup_state` → if `!isConfigured`, `chrome.tabs.create(setup.html)` and close the popup.
|
||||
2. `is_unlocked` → if unlocked, `list_items` + render list.
|
||||
3. Otherwise render `unlock`.
|
||||
4. Snapshot `(activeTabId, activeTabUrl)` at init; stash on `PopupState` for later `fill_credentials` calls.
|
||||
|
||||
### Item list / detail / form — Login-only
|
||||
|
||||
Rename `entry-*.ts` → `item-*.ts`. List view renders from `ManifestEntry`, shows type-icon + title + group + favorite + tags. Detail and form dispatch on `item.type`:
|
||||
|
||||
```ts
|
||||
switch (item.type) {
|
||||
case 'login': return renderLoginDetail(app, item);
|
||||
case 'secure_note':
|
||||
case 'identity':
|
||||
case 'card':
|
||||
case 'key':
|
||||
case 'document':
|
||||
case 'totp': return renderComingSoonPlaceholder(app, item.type);
|
||||
}
|
||||
```
|
||||
|
||||
Add flow: "New…" menu lists all seven types; picking Login opens the form, picking any other type shows "Coming in 1C-β".
|
||||
|
||||
Existing Login form (username/url/password/totp/group/notes) maps 1:1 to `LoginCore` + `Item` envelope. TOTP field takes a base32 string; `shared/base32.ts` parses it to a `number[]` (the `secret: Vec<u8>` on the Rust side) and emits `TotpConfig { secret, algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp' }`. Display is base32 re-encoded with a reveal toggle.
|
||||
|
||||
"gen" button sends:
|
||||
|
||||
```ts
|
||||
{ type: 'generate_password',
|
||||
request: { kind: 'random',
|
||||
length: 20,
|
||||
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||
symbol_charset: { kind: 'safe_only' } } }
|
||||
```
|
||||
|
||||
### Setup wizard + zxcvbn
|
||||
|
||||
Both `popup/components/setup-wizard.ts` and `setup/setup.ts`:
|
||||
|
||||
- Passphrase input with a 5-bar strength indicator (color-coded per zxcvbn `score`).
|
||||
- On input (150ms debounce): `rate_passphrase { passphrase }` → `{ score, guesses_log10 }`.
|
||||
- Submit disabled unless `score >= 3`.
|
||||
- Copy: `score < 3` → "Too weak — try a longer phrase or add unpredictability."; `score >= 3` → "Strong enough."
|
||||
|
||||
Reference-image upload and vault-config fields stay as-is. Final step gains the spec's H8 warning copy: "factor 2 + git push token are readable from this browser profile's disk. Your remaining defense is the passphrase."
|
||||
|
||||
## Storage Split
|
||||
|
||||
| Data | Location | Rationale |
|
||||
|---|---|---|
|
||||
| `vaultConfig` (host/repo/token) | `chrome.storage.local` | Device-local; needed pre-unlock |
|
||||
| `imageBase64` (reference JPEG) | `chrome.storage.local` | Device-local factor-2 material |
|
||||
| `DeviceSettings { captureEnabled, captureStyle }` | `chrome.storage.local` (key: `relicarioSettings`) | Per-device UX preference |
|
||||
| `captureBlacklist: string[]` | `chrome.storage.local` | Per-device; avoids git churn |
|
||||
| `VaultSettings.autofill_origin_acks` | `settings.enc` in the repo | Per-vault security posture |
|
||||
| Items / manifest / settings | git repo (AEAD'd) | Core vault state |
|
||||
|
||||
Existing `relicarioSettings` + `captureBlacklist` keys keep their names — no migration step.
|
||||
|
||||
## Firefox Parity
|
||||
|
||||
- `manifest.firefox.json` gets the same WAR cleanup as Chrome.
|
||||
- `background.scripts: ["service-worker.js"]` stays (not an SW in Firefox — persistent background script).
|
||||
- `initWasm()` in `service-worker/index.ts` already branches on `ServiceWorkerGlobalScope` presence; the branch survives the rewrite.
|
||||
- Load via `about:debugging` → "Load Temporary Add-on" → `dist-firefox/manifest.json`.
|
||||
- Final manual test matrix at the end of α runs on both browsers concurrently (see below).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Rust-side regression guard
|
||||
|
||||
Plan 1B's 151 tests must stay green. Every slice runs `cargo test --workspace` before commit. The WASM rebuild slice additionally runs `cargo build -p relicario-wasm --target wasm32-unknown-unknown`.
|
||||
|
||||
### Extension unit tests (new)
|
||||
|
||||
New harness in `extension/src/service-worker/router/__tests__/router.test.ts` using Vitest (runs TS natively, no webpack dependency). Tests:
|
||||
|
||||
- **Sender-check matrix**: for each message type, accepted from the right sender and rejected (`unauthorized_sender`) from every wrong sender. Mocks `chrome.runtime.onMessage` by calling the dispatcher directly with fabricated `sender` objects.
|
||||
- **Origin-bound autofill**: `get_autofill_candidates` ignores any stray `url` field; only `sender.tab.url`'s hostname drives matching.
|
||||
- **`get_credentials` origin equality**: mismatched `LoginCore.url` hostname → `origin_mismatch`, no item data in response.
|
||||
- **`fill_credentials` captured-tab verification**: mismatched captured vs current tab → reject.
|
||||
- **`generate_password` wiring**: calls through to WASM with the expected request shape (smoke only; generator itself is Rust-tested).
|
||||
|
||||
Harness scope: SW only. Popup/content-script DOM tests are β.
|
||||
|
||||
### Shadow DOM probe (dev-build only)
|
||||
|
||||
Runtime assertion in a dev-mode content-script path: after rendering the capture prompt, `console.warn` if `document.querySelector('#relicario-save-btn') !== null` or if the host's `shadowRoot` getter returns non-null from page JS. Stripped in production.
|
||||
|
||||
### Manual test matrix — end of slice 6, on both Chrome and Firefox
|
||||
|
||||
1. Fresh install → setup wizard opens in a tab (not popup-embedded); zxcvbn slider responds; weak passphrase blocks submit.
|
||||
2. Unlock → list renders from manifest.
|
||||
3. Add Login with TOTP → sync → item appears on a second browser profile.
|
||||
4. Autofill icon in password field → click → fills (single-candidate path).
|
||||
5. Multiple candidates → picker → pick → fills.
|
||||
6. First autofill on a new hostname → origin-ack prompt → confirm → credentials arrive.
|
||||
7. Capture prompt on submitting a new login; "Save" adds item, "Never" blacklists, "×" dismisses.
|
||||
8. Edit Login → password rotates → field history captured (CLI cross-check: `relicario get --show` on second machine shows new password; item file's `field_history` populated).
|
||||
9. Delete Login → moves to trash (not in list; CLI `relicario list --trashed` shows it).
|
||||
10. Soft-lock via popup "lock" → list clears, re-unlock required.
|
||||
11. Cross-origin autofill attempt via devtools: craft a `get_credentials` from a page whose hostname differs from item's `LoginCore.url` → `origin_mismatch`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cargo test --workspace` green.
|
||||
- `npm run build:all` green in `extension/` (Chrome + Firefox bundles).
|
||||
- Router unit tests green.
|
||||
- All 11 manual-matrix steps pass on both Chrome and Firefox.
|
||||
- `git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/` → zero hits.
|
||||
- `git grep -n 'idfoto' extension/` → zero hits.
|
||||
- WAR in both manifests contains no HTML/JS/WASM artifacts.
|
||||
- `fetch(chrome.runtime.getURL('setup.html'))` from a content script fails (smoke test — confirms WAR removal took effect).
|
||||
|
||||
### Non-goals for α testing
|
||||
|
||||
- Automated browser integration (Playwright against a built extension) — γ.
|
||||
- Heap-snapshot verification that master_key bytes aren't visible from JS — manual-only per broader spec; formalized in γ.
|
||||
- Fuzz / property tests for the router — the message-type matrix is exhaustive.
|
||||
|
||||
## Audit Findings Addressed
|
||||
|
||||
| ID | Severity | How α addresses it |
|
||||
|---|---|---|
|
||||
| C1 | Critical | Setup wizard + WASM removed from WAR; sender check on `save_setup` |
|
||||
| C2 | Critical | Split message router with sender-based dispatch |
|
||||
| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI |
|
||||
| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match) |
|
||||
| H2 | High | `SessionHandle` opaque to JS; passphrase cleared from scope ASAP |
|
||||
| H3 | High | zxcvbn strength gate in setup wizard |
|
||||
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
|
||||
| L11 | Low | `textContent` rule subsumes escaping concerns in the content-script surface |
|
||||
|
||||
Remaining audit items from the typed-items spec (retention UI, attachment caps UI, device-management UI, full vault-settings view) land in β/γ.
|
||||
|
||||
## Open Questions Deferred to the Plan
|
||||
|
||||
- Exact Vitest configuration — first router-test slice surfaces the final shape (`vitest.config.ts`, `tsconfig` overrides, jsdom vs happy-dom for the origin-parsing paths).
|
||||
- Precise "pending ack" detection mechanism in the popup: poll `VaultSettings.autofill_origin_acks` on popup open vs a background-synthesized flag. Slice 6 decides based on latency feel.
|
||||
- Whether the TOTP parse helper belongs in `shared/base32.ts` or gets added to the WASM surface as `totp_config_from_base32`. First approach is simpler; the second is more robust to charset edge cases. Slice 2 decides when wiring the Login form.
|
||||
244
docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Plan 1C-α — Manual Test Matrix
|
||||
|
||||
Walkthrough for validating the extension on both Chrome and Firefox after the six-slice implementation.
|
||||
|
||||
Branch: `feature/typed-items-1c-alpha` @ `3238ef4` (tag candidate: `plan-1c-alpha-complete`)
|
||||
Worktree: `/home/alee/Sources/relicario/.worktrees/typed-items-1c-alpha`
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] **P1.** Bundles built:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-alpha/extension
|
||||
bun run build:all
|
||||
```
|
||||
Expected: "compiled with 2 warnings" (WASM size only) for each bundle. `dist/` and `dist-firefox/` populated.
|
||||
|
||||
- [ ] **P2.** Fresh-profile browsers ready (or existing profile's `chrome.storage.local` for this extension cleared). Stale `vaultConfig`/`imageBase64` from the pre-rename `idfoto` era must not persist.
|
||||
|
||||
- [ ] **P3.** Test git repo for the vault is reachable (SSH key / HTTPS PAT working). Use a throwaway repo to avoid polluting your real vault history.
|
||||
|
||||
- [ ] **P4.** Reference image ready (any JPEG; DCT-steg secret is embedded at init time).
|
||||
|
||||
---
|
||||
|
||||
## Loading
|
||||
|
||||
### Chrome
|
||||
- [ ] **L1.** `chrome://extensions` → Developer mode ON → "Load unpacked" → select `extension/dist/`.
|
||||
- [ ] **L2.** Toolbar icon visible (pin if needed).
|
||||
- [ ] **L3.** Click icon → first open triggers setup tab (not a popup-embedded wizard).
|
||||
|
||||
### Firefox
|
||||
- [ ] **L4.** `about:debugging#/runtime/this-firefox` → "Load Temporary Add-on…" → select `extension/dist-firefox/manifest.json`.
|
||||
- [ ] **L5.** Toolbar icon visible.
|
||||
- [ ] **L6.** Click icon → setup tab opens.
|
||||
|
||||
---
|
||||
|
||||
## 11-step core matrix — Chrome
|
||||
|
||||
**Notes column: write what you saw. Check box only when matching expected.**
|
||||
|
||||
### 1. Setup tab opens from popup (audit C1)
|
||||
|
||||
- [ ] **Do:** Fresh install, click toolbar icon.
|
||||
- [ ] **Expected:** `setup.html` opens in a new tab; popup closes immediately; WAR is empty so this MUST work via extension-origin tab, not WAR.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 2. zxcvbn gate in setup (audit H3)
|
||||
|
||||
- [ ] **Do:** Type weak passphrase like `password`.
|
||||
- [ ] **Expected:** Submit disabled, bar shows red/orange segments, feedback "Too weak…".
|
||||
- [ ] **Do:** Type stronger phrase until bar fills.
|
||||
- [ ] **Expected:** At score ≥ 3, submit enables, feedback "Strong enough."
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 3. Setup completes → unlock → list renders
|
||||
|
||||
- [ ] **Do:** Upload reference JPEG, fill vault config (git host/URL/repo/token), submit. Then open popup, enter passphrase, unlock.
|
||||
- [ ] **Expected:** Manifest decrypts client-side. Empty list view appears with toolbar (search, + New, sync, lock, ⚙).
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 4. Add Login with TOTP (typed-item wire format)
|
||||
|
||||
- [ ] **Do:** "+ New" → Login form. Fill:
|
||||
- title: `GitHub`
|
||||
- url: `https://github.com`
|
||||
- username: your handle
|
||||
- password: click "gen" (uses `DEFAULT_PASSWORD_REQUEST` — 20 chars, safe symbols)
|
||||
- totp: `JBSWY3DPEHPK3PXP` (well-known base32 test vector)
|
||||
- Save.
|
||||
- [ ] **Expected:** Row appears with 🔑 icon + title + favorite star position.
|
||||
- [ ] **Expected (CLI cross-check, optional):** From main worktree:
|
||||
```bash
|
||||
relicario list
|
||||
relicario get "GitHub" --show
|
||||
```
|
||||
Should show the same item. TOTP secret should decode identically.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 5. TOFU origin-ack prompt (audit C4 first half)
|
||||
|
||||
- [ ] **Do:** Navigate to `https://github.com/login`. Click the blue `id` icon next to the password field.
|
||||
- [ ] **Expected:** Closed Shadow DOM hint appears ("First autofill on github.com / Open relicario to confirm"). In DevTools, verify `document.querySelector('[data-rel]')` finds the host but `.shadowRoot` is `null` (closed mode).
|
||||
- [ ] **Expected:** No credentials fill on this click.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 6. Confirm origin + autofill fills correctly
|
||||
|
||||
- [ ] **Do:** Open popup (on the github.com tab). Look for a pending-ack prompt OR (α behavior) just confirm manually: any `get_credentials` call after the hostname is acked in `VaultSettings.autofill_origin_acks` will return credentials.
|
||||
- Simplest α path: click the item in the popup list, click "autofill" button. This uses the popup-captured tab state path (audit M5).
|
||||
- [ ] **Expected:** Username + password fields fill. On React/Vue sites, the native-setter trick fires input+change events.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 7. Multiple candidates → picker
|
||||
|
||||
- [ ] **Do:** Add a second Login for github.com with a different username. Back on `github.com/login`, click the `id` icon.
|
||||
- [ ] **Expected:** Picker shows both titles. Click one → fills that set.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 8. Capture prompt → `capture_save_login` flow (Slice 5 critical-fix)
|
||||
|
||||
- [ ] **Do:** Go to a site not in your vault. Fill signup form (or real trial). Submit.
|
||||
- [ ] **Expected:** Capture prompt appears inside closed Shadow DOM. No stable element IDs — running `document.querySelector('#relicario-save-btn')` in the page console returns `null`.
|
||||
- [ ] **Do:** Click "Save" in the prompt.
|
||||
- [ ] **Expected:** ✓ Saved confirmation; prompt dismisses. Open popup → item present in list with the new hostname as title.
|
||||
- [ ] **CRITICAL:** If "Save" silently fails, the `capture_save_login` content-callable handler is broken — file a bug before proceeding.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 9. Edit Login → password rotates; field history captured
|
||||
|
||||
- [ ] **Do:** Select the GitHub item → edit → change password → save.
|
||||
- [ ] **Expected:** Detail view shows new password on reveal. List's "modified" time updates.
|
||||
- [ ] **Expected (CLI cross-check):**
|
||||
```bash
|
||||
relicario get "GitHub" --show
|
||||
# confirm field_history now has entry for the old password
|
||||
```
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 10. Delete Login → soft-delete
|
||||
|
||||
- [ ] **Do:** Select an item → "trash" → confirm.
|
||||
- [ ] **Expected:** Row disappears from list immediately. Popup list filters `trashed_at !== undefined`.
|
||||
- [ ] **Expected (CLI cross-check):** `relicario list --trashed` shows the item.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### 11. Lock → re-unlock
|
||||
|
||||
- [ ] **Do:** Click "lock" in the toolbar. Try to open the popup again.
|
||||
- [ ] **Expected:** Unlock screen. Session handle was cleared in WASM (not just JS).
|
||||
- [ ] **Do:** Re-unlock.
|
||||
- [ ] **Expected:** Same list (including the item from step 10 still in trash, invisible).
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
---
|
||||
|
||||
## 11-step core matrix — Firefox
|
||||
|
||||
Re-run 1–11 on Firefox. Critical Firefox-only check: the background script runs as a **persistent script** (not MV3 service worker); WASM loads via `initDefault(wasmUrl)` not `initSync`. Anything broken here that works in Chrome indicates WASM-loading drift.
|
||||
|
||||
- [ ] **FF1–FF11.** Re-run the 11 steps above. Summarize anomalies:
|
||||
- **Notes:** ___
|
||||
|
||||
---
|
||||
|
||||
## Security probes (bonus)
|
||||
|
||||
Open DevTools on any page (not extension origin) and try to defeat the router:
|
||||
|
||||
### SP1. Content-script-originated popup-only message
|
||||
|
||||
- [ ] **Do:** In a page console (not popup DevTools):
|
||||
```js
|
||||
chrome.runtime.sendMessage({ type: 'unlock', passphrase: 'guess' }, console.log)
|
||||
```
|
||||
- [ ] **Expected:** `{ ok: false, error: 'unauthorized_sender' }` (audit C2).
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### SP2. Cross-origin `get_credentials` attempt
|
||||
|
||||
- [ ] **Do:** Pick an item id from the popup (e.g., via popup DevTools: `copy(currentState.selectedId)`). Go to a **different-origin** page's console:
|
||||
```js
|
||||
chrome.runtime.sendMessage({ type: 'get_credentials', id: '<the-id>' }, console.log)
|
||||
```
|
||||
- [ ] **Expected:** `{ ok: false, error: 'origin_mismatch' }` (audit C4). No item data leaks.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### SP3. Closed Shadow DOM verification
|
||||
|
||||
- [ ] **Do:** Trigger the capture prompt (step 8). In the page console:
|
||||
```js
|
||||
const hosts = document.querySelectorAll('[data-rel]');
|
||||
for (const h of hosts) console.log(h, h.shadowRoot); // shadowRoot should be null
|
||||
console.log(document.querySelector('#relicario-save-btn')); // should be null
|
||||
console.log(document.querySelector('.relicario-capture')); // should be null
|
||||
```
|
||||
- [ ] **Expected:** All `shadowRoot` values are `null`; no stable selectors match (audit C3).
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
### SP4. Captured-tab navigation during fill (audit M5)
|
||||
|
||||
- [ ] **Do:** Open popup on `https://github.com/login`. Select a github item, click "autofill", but BEFORE the fill lands, rapidly navigate the github tab to `https://example.com`.
|
||||
- [ ] **Expected:** No credentials typed on example.com. SW rejects with `tab_navigated`; if somehow the message reaches the content script, `fill.ts` re-checks `expectedHost` and rejects with `origin_changed`.
|
||||
- [ ] **Notes:** ___ (this one's hard to time; skip if not easily reproducible)
|
||||
|
||||
### SP5. WAR probe
|
||||
|
||||
- [ ] **Do:** In a page console on any site:
|
||||
```js
|
||||
fetch('chrome-extension://<your-extension-id>/setup.html').catch(e => console.log('blocked:', e))
|
||||
```
|
||||
- [ ] **Expected:** Blocked (either CORS error or net::ERR). WAR is empty, so no resource is web-accessible. `<all_urls>` pages cannot reach setup.html.
|
||||
- [ ] **Notes:** ___
|
||||
|
||||
---
|
||||
|
||||
## Final acceptance
|
||||
|
||||
- [ ] **A1.** `cargo test --workspace` green (should still be 151+ Rust tests).
|
||||
- [ ] **A2.** `cd extension && bun run test` green (should be 52 passing — 11 base32 + 41 router).
|
||||
- [ ] **A3.** `cd extension && bun run build` green (Chrome bundle).
|
||||
- [ ] **A4.** `cd extension && bun run build:firefox` green (Firefox bundle).
|
||||
- [ ] **A5.** Lint greps clean:
|
||||
```bash
|
||||
git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ # zero hits
|
||||
git grep -n 'idfoto' extension/ # zero hits
|
||||
git grep -n '@ts-nocheck' extension/src/ # zero hits
|
||||
```
|
||||
- [ ] **A6.** WAR empty:
|
||||
```bash
|
||||
grep -A2 web_accessible_resources extension/manifest.json # []
|
||||
grep -A2 web_accessible_resources extension/manifest.firefox.json # []
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
- [ ] **All 11 core-matrix steps pass on Chrome**
|
||||
- [ ] **All 11 core-matrix steps pass on Firefox**
|
||||
- [ ] **All 5 security probes pass (or SP4 skipped, others pass)**
|
||||
- [ ] **All 6 final acceptance checks pass**
|
||||
- [ ] **Ready to tag `plan-1c-alpha-complete` and decide on merge path**
|
||||
|
||||
### Findings / issues
|
||||
|
||||
Use this space to log anything weird:
|
||||
|
||||
```
|
||||
(fill in as you go)
|
||||
```
|
||||
|
||||
### Decision
|
||||
|
||||
- [ ] Merge straight to `main`
|
||||
- [ ] Open a PR first for review
|
||||
- [ ] Need rework on: ___
|
||||
|
||||
---
|
||||
|
||||
*Generated 2026-04-20 — source: spec `2026-04-20-relicario-extension-1c-alpha-design.md` §5.4, plan `2026-04-20-relicario-extension-1c-alpha.md` Task 27.*
|
||||
@@ -3,12 +3,14 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "idfoto-extension",
|
||||
"name": "relicario-extension",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"copy-webpack-plugin": "^12.0",
|
||||
"happy-dom": "^15",
|
||||
"ts-loader": "^9.5",
|
||||
"typescript": "^5.4",
|
||||
"vitest": "^2.0",
|
||||
"webpack": "^5.90",
|
||||
"webpack-cli": "^5.1",
|
||||
},
|
||||
@@ -17,6 +19,52 @@
|
||||
"packages": {
|
||||
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
@@ -33,6 +81,56 @@
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="],
|
||||
|
||||
"@types/chrome": ["@types/chrome@0.1.40", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA=="],
|
||||
@@ -53,6 +151,20 @@
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
|
||||
|
||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
||||
@@ -105,6 +217,8 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -113,10 +227,16 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
|
||||
|
||||
"clone-deep": ["clone-deep@4.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", "shallow-clone": "^3.0.0" } }, "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ=="],
|
||||
@@ -133,16 +253,24 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
@@ -151,8 +279,12 @@
|
||||
|
||||
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -169,6 +301,8 @@
|
||||
|
||||
"flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
@@ -179,6 +313,8 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"happy-dom": ["happy-dom@15.11.7", "", { "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
@@ -215,6 +351,10 @@
|
||||
|
||||
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
@@ -225,6 +365,10 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
@@ -245,12 +389,18 @@
|
||||
|
||||
"path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="],
|
||||
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
@@ -267,6 +417,8 @@
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
@@ -283,12 +435,20 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
@@ -299,6 +459,16 @@
|
||||
|
||||
"terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"ts-loader": ["ts-loader@9.5.7", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", "micromatch": "^4.0.0", "semver": "^7.3.4", "source-map": "^0.7.4" }, "peerDependencies": { "typescript": "*", "webpack": "^5.0.0" } }, "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg=="],
|
||||
@@ -311,8 +481,16 @@
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
|
||||
|
||||
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
|
||||
|
||||
"watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"webpack": ["webpack@5.106.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-EW8af29ak8Oaf4T8k8YsajjrDBDYgnKZ5er6ljWFJsXABfTNowQfvHLftwcepVgdz+IoLSdEAbBiM9DFXoll9w=="],
|
||||
|
||||
"webpack-cli": ["webpack-cli@5.1.4", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", "@webpack-cli/info": "^2.0.2", "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", "commander": "^10.0.1", "cross-spawn": "^7.0.3", "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "peerDependencies": { "webpack": "5.x.x" }, "bin": { "webpack-cli": "bin/cli.js" } }, "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg=="],
|
||||
@@ -321,8 +499,12 @@
|
||||
|
||||
"webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wildcard": ["wildcard@2.0.1", "", {}, "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="],
|
||||
|
||||
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
@@ -334,5 +516,7 @@
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 886 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.2 KiB |
24
extension/icons/relicario-logo-16.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<!-- 16x16-optimized: bolder strokes, simplified details, single gem
|
||||
facet for crisp pixels at toolbar size. -->
|
||||
|
||||
<!-- Base plate -->
|
||||
<rect x="1" y="13" width="14" height="2" rx="0.5" fill="#58a6ff"/>
|
||||
|
||||
<!-- Arched reliquary body -->
|
||||
<path d="M 3 13
|
||||
L 3 6
|
||||
C 3 3.5, 5 2, 8 2
|
||||
C 11 2, 13 3.5, 13 6
|
||||
L 13 13 Z"
|
||||
fill="#161b22"
|
||||
stroke="#58a6ff"
|
||||
stroke-width="1"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Seal band -->
|
||||
<rect x="3" y="6" width="10" height="1" fill="#58a6ff"/>
|
||||
|
||||
<!-- Central gem — a simple filled diamond -->
|
||||
<path d="M 8 8 L 10 10 L 8 12 L 6 10 Z" fill="#58a6ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 745 B |
@@ -1,30 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<!-- ID card outer body -->
|
||||
<rect x="16" y="20" width="96" height="88" rx="6" fill="#0d1117" stroke="#58a6ff" stroke-width="3"/>
|
||||
<!-- relicario: a reliquary — a vessel that holds precious things.
|
||||
Arched container with a horizontal seal band, a central gem
|
||||
(the "relic"), standing on a base plate.
|
||||
Palette: gh-dark #0d1117/#161b22 background, #58a6ff primary,
|
||||
#79c0ff / #1f6feb gem facets. -->
|
||||
|
||||
<!-- Photo rectangle (left side, dominant) -->
|
||||
<rect x="26" y="32" width="44" height="64" rx="2" fill="#58a6ff"/>
|
||||
<!-- Base plate / pedestal — extends slightly beyond the body. -->
|
||||
<rect x="18" y="104" width="92" height="10" rx="2" fill="#58a6ff"/>
|
||||
<rect x="18" y="112" width="92" height="2" fill="#1f6feb"/>
|
||||
|
||||
<!-- Silhouette in photo (cartoony connected portrait) -->
|
||||
<path d="M 28 96
|
||||
L 28 92
|
||||
C 28 84, 34 79, 42 77
|
||||
L 42 73
|
||||
C 38 71, 36 66, 36 60
|
||||
C 36 52, 41 46, 48 46
|
||||
C 55 46, 60 52, 60 60
|
||||
C 60 66, 58 71, 54 73
|
||||
L 54 77
|
||||
C 62 79, 68 84, 68 92
|
||||
L 68 96 Z" fill="#0d1117"/>
|
||||
<!-- Reliquary body: rounded arch over a rectangular casket. -->
|
||||
<path d="M 28 104
|
||||
L 28 54
|
||||
C 28 34, 44 20, 64 20
|
||||
C 84 20, 100 34, 100 54
|
||||
L 100 104 Z"
|
||||
fill="#161b22"
|
||||
stroke="#58a6ff"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Info lines (right side, suggest text without being text) -->
|
||||
<rect x="78" y="36" width="24" height="4" rx="1" fill="#58a6ff"/>
|
||||
<rect x="78" y="48" width="20" height="3" rx="1" fill="#30363d"/>
|
||||
<rect x="78" y="56" width="24" height="3" rx="1" fill="#30363d"/>
|
||||
<rect x="78" y="64" width="18" height="3" rx="1" fill="#30363d"/>
|
||||
<!-- Lock icon (security indicator) -->
|
||||
<rect x="82" y="84" width="16" height="12" rx="1.5" fill="#3fb950"/>
|
||||
<path d="M85 84 V79 A5 5 0 0 1 95 79 V84" fill="none" stroke="#3fb950" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="90" cy="90" r="1.5" fill="#0d1117"/>
|
||||
<!-- Horizontal seal band across the arch-to-body transition. -->
|
||||
<rect x="26" y="56" width="76" height="5" fill="#58a6ff"/>
|
||||
|
||||
<!-- Small rivets at each end of the seal band. -->
|
||||
<circle cx="32" cy="58.5" r="2" fill="#0d1117"/>
|
||||
<circle cx="96" cy="58.5" r="2" fill="#0d1117"/>
|
||||
|
||||
<!-- The relic: a faceted diamond/gem centered in the casket chamber.
|
||||
Three tones suggest light hitting facets. -->
|
||||
<g transform="translate(64, 80)">
|
||||
<path d="M 0 -18 L 16 0 L 0 22 L -16 0 Z" fill="#58a6ff"/>
|
||||
<path d="M 0 -18 L 16 0 L 0 0 Z" fill="#79c0ff"/>
|
||||
<path d="M -16 0 L 0 -18 L 0 0 Z" fill="#1f6feb"/>
|
||||
<path d="M 0 22 L 16 0 L 0 0 Z" fill="#1f6feb" opacity="0.7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -35,7 +35,5 @@
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"web_accessible_resources": [{
|
||||
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"]
|
||||
}]
|
||||
"web_accessible_resources": []
|
||||
}
|
||||
|
||||
@@ -30,8 +30,5 @@
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"web_accessible_resources": [{
|
||||
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}]
|
||||
"web_accessible_resources": []
|
||||
}
|
||||
|
||||
@@ -8,13 +8,17 @@
|
||||
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
|
||||
"dev": "webpack --mode development --watch",
|
||||
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
|
||||
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm"
|
||||
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"copy-webpack-plugin": "^12.0",
|
||||
"happy-dom": "^15",
|
||||
"ts-loader": "^9.5",
|
||||
"typescript": "^5.4",
|
||||
"vitest": "^2.0",
|
||||
"webpack": "^5.90",
|
||||
"webpack-cli": "^5.1"
|
||||
}
|
||||
|
||||
@@ -49,23 +49,125 @@
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar .seg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: #21262d;
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
transition: background 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.strength-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s, background 0.2s;
|
||||
/* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */
|
||||
.strength-bar.s0 .seg.i0 { background: #f85149; }
|
||||
.strength-bar.s1 .seg.i0,
|
||||
.strength-bar.s1 .seg.i1 { background: #f08d49; }
|
||||
.strength-bar.s2 .seg.i0,
|
||||
.strength-bar.s2 .seg.i1,
|
||||
.strength-bar.s2 .seg.i2 { background: #d29922; }
|
||||
.strength-bar.s3 .seg.i0,
|
||||
.strength-bar.s3 .seg.i1,
|
||||
.strength-bar.s3 .seg.i2,
|
||||
.strength-bar.s3 .seg.i3 { background: #3fb950; }
|
||||
.strength-bar.s4 .seg {
|
||||
background: #56d364;
|
||||
box-shadow: 0 0 4px rgba(86, 211, 100, 0.4);
|
||||
}
|
||||
|
||||
.strength-bar-fill.weak { background: #f85149; width: 25%; }
|
||||
.strength-bar-fill.fair { background: #d29922; width: 50%; }
|
||||
.strength-bar-fill.good { background: #3fb950; width: 75%; }
|
||||
.strength-bar-fill.strong { background: #58a6ff; width: 100%; }
|
||||
.strength-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.strength-label.s-very-weak { color: #f85149; }
|
||||
.strength-label.s-weak { color: #f08d49; }
|
||||
.strength-label.s-fair { color: #d29922; }
|
||||
.strength-label.s-good { color: #3fb950; }
|
||||
.strength-label.s-strong { color: #56d364; font-weight: 600; }
|
||||
|
||||
.char-counter {
|
||||
font-size: 10px;
|
||||
color: #6e7681;
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entropy-line {
|
||||
font-size: 10px;
|
||||
color: #8b949e;
|
||||
margin-top: 2px;
|
||||
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.pass-help {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-left: 2px solid #1f6feb;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
line-height: 1.55;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.pass-help strong { color: #c9d1d9; }
|
||||
|
||||
.passphrase-field {
|
||||
position: relative;
|
||||
}
|
||||
.passphrase-field input {
|
||||
padding-right: 76px; /* room for match indicator + eye button */
|
||||
}
|
||||
.eye-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 3px;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.eye-btn:hover { color: #c9d1d9; border-color: #484f58; }
|
||||
|
||||
.match-indicator {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.match-indicator.ok { color: #3fb950; }
|
||||
.match-indicator.bad { color: #f85149; }
|
||||
|
||||
/* Primary button explicitly dims when disabled so the gate is obvious. */
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
background: #0d1b0e;
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
///
|
||||
/// Detects login form submissions and prompts the user to save or update
|
||||
/// credentials in the vault. Supports bar and toast prompt styles.
|
||||
///
|
||||
/// The prompt renders inside a closed Shadow DOM so the host page cannot
|
||||
/// read overlay contents via document.querySelector or rewrite them via
|
||||
/// insertAdjacentHTML. All caller-supplied strings (hostname, username)
|
||||
/// are applied via textContent, never innerHTML.
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type { RelicarioSettings } from '../shared/types';
|
||||
import type { DeviceSettings } from '../shared/types';
|
||||
import { createShadowHost, type ShadowSurface } from './shadow';
|
||||
|
||||
// --- State ---
|
||||
|
||||
const hookedForms = new WeakSet<HTMLFormElement>();
|
||||
const hookedButtons = new WeakSet<HTMLElement>();
|
||||
let currentPrompt: ShadowSurface | null = null;
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
@@ -73,11 +80,10 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
|
||||
if (!password) return;
|
||||
|
||||
const username = findUsernameValue(pwField);
|
||||
const url = window.location.href;
|
||||
|
||||
// Note: `url` is NOT sent — router derives origin from sender.tab.url.
|
||||
const resp = await sendMessage({
|
||||
type: 'check_credential',
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
@@ -87,60 +93,65 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
|
||||
const data = resp.data as { action: string; entryId?: string; entryName?: string };
|
||||
if (data.action === 'skip') return;
|
||||
|
||||
// Fetch settings for prompt style
|
||||
const settingsResp = await sendMessage({ type: 'get_settings' });
|
||||
const settings: RelicarioSettings = settingsResp.ok
|
||||
? (settingsResp.data as { settings: RelicarioSettings }).settings
|
||||
: { captureEnabled: true, captureStyle: 'bar' };
|
||||
// Fetch settings for prompt style. Content scripts have direct
|
||||
// chrome.storage.local access (manifest grants "storage"), so we don't
|
||||
// need to round-trip through the SW for this — which also avoids the
|
||||
// router's content→popup-only rejection for 'get_settings'.
|
||||
const stored = await chrome.storage.local.get('relicarioSettings');
|
||||
const settings: DeviceSettings = (stored.relicarioSettings as DeviceSettings)
|
||||
?? { captureEnabled: true, captureStyle: 'bar' };
|
||||
|
||||
showPrompt(settings.captureStyle, data.action, url, username, password, data.entryId);
|
||||
showPrompt(settings.captureStyle, data.action, username, password);
|
||||
}
|
||||
|
||||
// --- Prompt UI ---
|
||||
|
||||
function removeExistingPrompt(): void {
|
||||
const existing = document.getElementById('relicario-capture-prompt');
|
||||
if (existing) existing.remove();
|
||||
if (currentPrompt) {
|
||||
currentPrompt.destroy();
|
||||
currentPrompt = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showPrompt(
|
||||
style: 'bar' | 'toast',
|
||||
action: string,
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
entryId?: string,
|
||||
): void {
|
||||
removeExistingPrompt();
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
} catch {
|
||||
hostname = url;
|
||||
const hostname = (() => {
|
||||
try { return new URL(window.location.href).hostname; } catch { return window.location.href; }
|
||||
})();
|
||||
|
||||
const surface = createShadowHost();
|
||||
currentPrompt = surface;
|
||||
const { host, root } = surface;
|
||||
|
||||
// Position the host on the page; all further styling lives inside the
|
||||
// shadow root so the page's CSS can't reach us.
|
||||
const baseHostStyles = 'z-index: 2147483647; position: fixed;';
|
||||
if (style === 'bar') {
|
||||
host.style.cssText = `${baseHostStyles} top:0; left:0; right:0;`;
|
||||
} else {
|
||||
host.style.cssText = `${baseHostStyles} bottom:16px; right:16px;`;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.id = 'relicario-capture-prompt';
|
||||
// --- Build prompt DOM via createElement / textContent only ---
|
||||
|
||||
// Common styles
|
||||
const baseStyles = [
|
||||
const container = document.createElement('div');
|
||||
const containerBase = [
|
||||
'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace',
|
||||
'font-size: 13px',
|
||||
'color: #c9d1d9',
|
||||
'background: #161b22',
|
||||
'z-index: 2147483647',
|
||||
'box-sizing: border-box',
|
||||
'line-height: 1.4',
|
||||
];
|
||||
|
||||
if (style === 'bar') {
|
||||
container.style.cssText = [
|
||||
...baseStyles,
|
||||
'position: fixed',
|
||||
'top: 0',
|
||||
'left: 0',
|
||||
'right: 0',
|
||||
...containerBase,
|
||||
'padding: 10px 16px',
|
||||
'display: flex',
|
||||
'align-items: center',
|
||||
@@ -152,10 +163,7 @@ function showPrompt(
|
||||
].join('; ');
|
||||
} else {
|
||||
container.style.cssText = [
|
||||
...baseStyles,
|
||||
'position: fixed',
|
||||
'bottom: 16px',
|
||||
'right: 16px',
|
||||
...containerBase,
|
||||
'padding: 12px 16px',
|
||||
'border-radius: 4px',
|
||||
'border: 1px solid #30363d',
|
||||
@@ -167,30 +175,46 @@ function showPrompt(
|
||||
}
|
||||
|
||||
const actionLabel = action === 'update' ? 'Update' : 'Save';
|
||||
const displayUser = username ? ` (${username})` : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
${actionLabel} login for <strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong>${escapeForHtml(displayUser)}?
|
||||
</span>
|
||||
<button id="relicario-save-btn" style="
|
||||
background:#1f6feb; color:#fff; border:none; padding:5px 14px;
|
||||
border-radius:3px; cursor:pointer; font-family:inherit; font-size:12px;
|
||||
white-space:nowrap;
|
||||
">${actionLabel}</button>
|
||||
<button id="relicario-never-btn" style="
|
||||
background:transparent; color:#8b949e; border:1px solid #30363d;
|
||||
padding:5px 10px; border-radius:3px; cursor:pointer;
|
||||
font-family:inherit; font-size:12px; white-space:nowrap;
|
||||
">Never</button>
|
||||
<button id="relicario-close-btn" style="
|
||||
background:transparent; color:#8b949e; border:none;
|
||||
cursor:pointer; font-size:16px; padding:2px 6px;
|
||||
font-family:inherit; line-height:1;
|
||||
">\u2715</button>
|
||||
`;
|
||||
// Message span: "<actionLabel> login for <hostname>(<username>)?"
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.style.cssText = 'flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
|
||||
msgSpan.appendChild(document.createTextNode(`${actionLabel} login for `));
|
||||
const hostStrong = document.createElement('strong');
|
||||
hostStrong.style.color = '#58a6ff';
|
||||
hostStrong.textContent = hostname;
|
||||
msgSpan.appendChild(hostStrong);
|
||||
if (username) {
|
||||
msgSpan.appendChild(document.createTextNode(` (${username})`));
|
||||
}
|
||||
msgSpan.appendChild(document.createTextNode('?'));
|
||||
|
||||
document.body.appendChild(container);
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.textContent = actionLabel;
|
||||
saveBtn.style.cssText = [
|
||||
'background:#1f6feb', 'color:#fff', 'border:none', 'padding:5px 14px',
|
||||
'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px',
|
||||
'white-space:nowrap',
|
||||
].join('; ');
|
||||
|
||||
const neverBtn = document.createElement('button');
|
||||
neverBtn.textContent = 'Never';
|
||||
neverBtn.style.cssText = [
|
||||
'background:transparent', 'color:#8b949e', 'border:1px solid #30363d',
|
||||
'padding:5px 10px', 'border-radius:3px', 'cursor:pointer',
|
||||
'font-family:inherit', 'font-size:12px', 'white-space:nowrap',
|
||||
].join('; ');
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = '✕';
|
||||
closeBtn.style.cssText = [
|
||||
'background:transparent', 'color:#8b949e', 'border:none',
|
||||
'cursor:pointer', 'font-size:16px', 'padding:2px 6px',
|
||||
'font-family:inherit', 'line-height:1',
|
||||
].join('; ');
|
||||
|
||||
container.append(msgSpan, saveBtn, neverBtn, closeBtn);
|
||||
root.appendChild(container);
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => {
|
||||
@@ -211,68 +235,35 @@ function showPrompt(
|
||||
if (autoDismissTimer) clearTimeout(autoDismissTimer);
|
||||
};
|
||||
|
||||
// Save button
|
||||
container.querySelector('#relicario-save-btn')?.addEventListener('click', async () => {
|
||||
// Save button — single content-callable message; the SW figures out
|
||||
// whether this is an add or an update (and enforces origin-binding).
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
clearAutoDismiss();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (action === 'update' && entryId) {
|
||||
await sendMessage({
|
||||
type: 'update_entry',
|
||||
id: entryId,
|
||||
entry: {
|
||||
name: hostname,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await sendMessage({
|
||||
type: 'add_entry',
|
||||
entry: {
|
||||
name: hostname,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
const resp = await sendMessage({ type: 'capture_save_login', username, password });
|
||||
if (!resp.ok) {
|
||||
msgSpan.textContent = `✗ ${resp.error}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
const span = container.querySelector('span');
|
||||
if (span) span.textContent = '\u2713 Saved';
|
||||
const saveBtn = container.querySelector('#relicario-save-btn') as HTMLElement | null;
|
||||
const neverBtn = container.querySelector('#relicario-never-btn') as HTMLElement | null;
|
||||
if (saveBtn) saveBtn.style.display = 'none';
|
||||
if (neverBtn) neverBtn.style.display = 'none';
|
||||
msgSpan.textContent = '✓ Saved';
|
||||
saveBtn.style.display = 'none';
|
||||
neverBtn.style.display = 'none';
|
||||
setTimeout(() => removeExistingPrompt(), 1500);
|
||||
});
|
||||
|
||||
// Never button
|
||||
container.querySelector('#relicario-never-btn')?.addEventListener('click', async () => {
|
||||
// Never button: router derives hostname from sender.tab.url (no `hostname` field)
|
||||
neverBtn.addEventListener('click', async () => {
|
||||
clearAutoDismiss();
|
||||
await sendMessage({ type: 'blacklist_site', hostname });
|
||||
await sendMessage({ type: 'blacklist_site' });
|
||||
removeExistingPrompt();
|
||||
});
|
||||
|
||||
// Close button
|
||||
container.querySelector('#relicario-close-btn')?.addEventListener('click', () => {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
clearAutoDismiss();
|
||||
removeExistingPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeForHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Form hooking ---
|
||||
|
||||
export function hookForms(): void {
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
/// Fill listener — receives credentials from the service worker and fills form fields.
|
||||
/// Fill listener — receives credentials from the service worker popup flow,
|
||||
/// verifies origin, and fills page fields.
|
||||
///
|
||||
/// Uses the native value setter trick to work with React/Vue controlled inputs
|
||||
/// that override the value property.
|
||||
/// TOCTOU mitigation: the popup captures its active tab at open time and
|
||||
/// passes {capturedTabId, capturedUrl, expectedHost} to the SW. The SW
|
||||
/// re-fetches the tab and checks the hostname against `capturedUrl` before
|
||||
/// forwarding, but between the SW's chrome.tabs.sendMessage and our receipt
|
||||
/// the page could navigate. We re-check `location.href.hostname ===
|
||||
/// expectedHost` before typing credentials. If the page has navigated
|
||||
/// (different origin now running the content script), reply with
|
||||
/// `origin_changed` and do nothing.
|
||||
|
||||
/// Message shape forwarded by router/popup-only.ts#handleFillCredentials.
|
||||
export interface FillMessage {
|
||||
type: 'fill_credentials';
|
||||
username: string;
|
||||
password: string;
|
||||
/// The hostname the SW validated the captured tab was on. The content
|
||||
/// script rejects delivery if the page has since navigated away.
|
||||
expectedHost: string;
|
||||
}
|
||||
|
||||
/// Set up a listener for fill_credentials messages from the service worker.
|
||||
export function setupFillListener(): void {
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(message: { type: string; username: string; password: string }, _sender: chrome.runtime.MessageSender, sendResponse: (response: { ok: boolean }) => void) => {
|
||||
(
|
||||
message: FillMessage,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: { ok: boolean; error?: string }) => void,
|
||||
) => {
|
||||
if (message.type !== 'fill_credentials') return false;
|
||||
const currentHost = (() => {
|
||||
try { return new URL(location.href).hostname; } catch { return ''; }
|
||||
})();
|
||||
if (!currentHost || currentHost !== message.expectedHost) {
|
||||
sendResponse({ ok: false, error: 'origin_changed' });
|
||||
return false;
|
||||
}
|
||||
fillFields(message.username, message.password);
|
||||
sendResponse({ ok: true });
|
||||
return false;
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
/// Inject a small "id" icon into password fields for quick autofill access.
|
||||
///
|
||||
/// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver).
|
||||
/// Each injected icon and picker renders inside a closed Shadow DOM so
|
||||
/// the host page cannot read or manipulate our UI.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Icon click → chrome.runtime.sendMessage({ type: 'get_autofill_candidates' })
|
||||
/// (router derives origin from sender.tab.url; no url on message).
|
||||
/// 2. Single candidate → get_credentials; if response is a
|
||||
/// requires_ack variant, show an in-page TOFU hint instructing the
|
||||
/// user to open the popup for ack. Otherwise, call fillFields()
|
||||
/// directly — the content script IS the page origin, so no SW
|
||||
/// round-trip for the fill itself.
|
||||
/// 3. Multiple candidates → show the picker inside a shadow root.
|
||||
///
|
||||
/// Note: fill_credentials is popup-only in the router. The icon click path
|
||||
/// cannot and MUST NOT issue fill_credentials from content.
|
||||
|
||||
import type { ManifestEntry } from '../shared/types';
|
||||
import type { AutofillCandidatesResponse, CredentialsResponse, Response } from '../shared/messages';
|
||||
import type { ManifestEntry, ItemId } from '../shared/types';
|
||||
import { createShadowHost, type ShadowSurface } from './shadow';
|
||||
import { fillFields } from './fill';
|
||||
|
||||
/// Track which fields already have an injected icon.
|
||||
const injected = new WeakSet<HTMLInputElement>();
|
||||
|
||||
/// The currently-open picker / TOFU hint, if any.
|
||||
let currentOverlay: ShadowSurface | null = null;
|
||||
|
||||
function closeOverlay(): void {
|
||||
if (currentOverlay) {
|
||||
currentOverlay.destroy();
|
||||
currentOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a small blue "id" icon at the right edge of a password field.
|
||||
/// Clicking it queries for autofill candidates and either fills immediately
|
||||
/// (single match) or shows an inline picker (multiple matches).
|
||||
export function injectFieldIcons(
|
||||
passwordField: HTMLInputElement,
|
||||
_usernameField: HTMLInputElement | null,
|
||||
@@ -17,145 +42,190 @@ export function injectFieldIcons(
|
||||
if (injected.has(passwordField)) return;
|
||||
injected.add(passwordField);
|
||||
|
||||
// Create the icon element.
|
||||
// Each icon gets its own shadow host so page CSS cannot reach it.
|
||||
const surface = createShadowHost();
|
||||
const { host, root } = surface;
|
||||
|
||||
// Compute initial position from the password field's bounding rect and
|
||||
// reposition on scroll/resize. We keep things lightweight — exact
|
||||
// pixel-perfect tracking during layout churn is not required.
|
||||
function positionHost(): void {
|
||||
const rect = passwordField.getBoundingClientRect();
|
||||
host.style.cssText = [
|
||||
'position: fixed',
|
||||
`top: ${rect.top + rect.height / 2 - 10}px`,
|
||||
`left: ${rect.right - 28}px`,
|
||||
'z-index: 2147483646',
|
||||
'pointer-events: auto',
|
||||
].join('; ');
|
||||
}
|
||||
positionHost();
|
||||
window.addEventListener('scroll', positionHost, true);
|
||||
window.addEventListener('resize', positionHost);
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.textContent = 'id';
|
||||
icon.setAttribute('role', 'button');
|
||||
icon.setAttribute('aria-label', 'relicario autofill');
|
||||
icon.style.cssText = [
|
||||
'width: 20px', 'height: 20px', 'line-height: 20px',
|
||||
'text-align: center', 'font-size: 10px', 'font-weight: 700',
|
||||
'font-family: monospace', 'color: #fff', 'background: #1f6feb',
|
||||
'border-radius: 3px', 'cursor: pointer', 'user-select: none',
|
||||
'box-sizing: border-box',
|
||||
].join('; ');
|
||||
root.appendChild(icon);
|
||||
|
||||
Object.assign(icon.style, {
|
||||
position: 'absolute',
|
||||
right: '8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
lineHeight: '20px',
|
||||
textAlign: 'center',
|
||||
fontSize: '10px',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'monospace',
|
||||
color: '#fff',
|
||||
background: '#1f6feb',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
zIndex: '999999',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
// Ensure the password field's parent is positioned so the icon can be absolute.
|
||||
const parent = passwordField.parentElement;
|
||||
if (parent) {
|
||||
const parentPosition = getComputedStyle(parent).position;
|
||||
if (parentPosition === 'static') {
|
||||
parent.style.position = 'relative';
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the icon after the password field.
|
||||
passwordField.insertAdjacentElement('afterend', icon);
|
||||
|
||||
// Click handler: query for autofill candidates.
|
||||
icon.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const url = window.location.href;
|
||||
// Note: no `url` on message — router derives from sender.tab.url.
|
||||
const resp = await chrome.runtime.sendMessage({
|
||||
type: 'get_autofill_candidates',
|
||||
url,
|
||||
});
|
||||
}) as Response;
|
||||
|
||||
if (!resp || !resp.ok) return;
|
||||
const candidates = resp.data.candidates as Array<[string, ManifestEntry]>;
|
||||
|
||||
const candidates = (resp as AutofillCandidatesResponse).data.candidates;
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
if (candidates.length === 1) {
|
||||
// Single match — fill immediately.
|
||||
const [id] = candidates[0];
|
||||
const credResp = await chrome.runtime.sendMessage({
|
||||
type: 'get_credentials',
|
||||
id,
|
||||
});
|
||||
if (credResp?.ok) {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'fill_credentials',
|
||||
username: credResp.data.username,
|
||||
password: credResp.data.password,
|
||||
});
|
||||
}
|
||||
await handleSingleCandidate(candidates[0][0]);
|
||||
} else {
|
||||
// Multiple matches — show inline picker.
|
||||
showPicker(icon, candidates);
|
||||
showPicker(passwordField, candidates);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a small dropdown picker below the icon for selecting among multiple candidates.
|
||||
/// Fetch credentials for a single item and either fill immediately or
|
||||
/// display the TOFU ack hint.
|
||||
async function handleSingleCandidate(id: ItemId): Promise<void> {
|
||||
const credResp = await chrome.runtime.sendMessage({
|
||||
type: 'get_credentials',
|
||||
id,
|
||||
}) as Response;
|
||||
if (!credResp?.ok) return;
|
||||
|
||||
const data = (credResp as CredentialsResponse).data;
|
||||
if ('requires_ack' in data && data.requires_ack) {
|
||||
showAckHint(data.hostname);
|
||||
return;
|
||||
}
|
||||
// Discriminated union: must be the {username, password} variant here.
|
||||
if ('username' in data && 'password' in data) {
|
||||
fillFields(data.username, data.password);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a dropdown picker below the password field for selecting among
|
||||
/// multiple candidates. The picker lives in its own closed Shadow DOM.
|
||||
function showPicker(
|
||||
anchor: HTMLElement,
|
||||
candidates: Array<[string, ManifestEntry]>,
|
||||
anchor: HTMLInputElement,
|
||||
candidates: Array<[ItemId, ManifestEntry]>,
|
||||
): void {
|
||||
// Remove any existing picker.
|
||||
document.querySelectorAll('.relicario-picker').forEach(el => el.remove());
|
||||
closeOverlay();
|
||||
const surface = createShadowHost();
|
||||
currentOverlay = surface;
|
||||
const { host, root } = surface;
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
host.style.cssText = [
|
||||
'position: fixed',
|
||||
`top: ${rect.bottom + 4}px`,
|
||||
`left: ${rect.right - 180}px`,
|
||||
'z-index: 2147483647',
|
||||
].join('; ');
|
||||
|
||||
const picker = document.createElement('div');
|
||||
picker.className = 'relicario-picker';
|
||||
Object.assign(picker.style, {
|
||||
position: 'absolute',
|
||||
right: '0',
|
||||
top: '100%',
|
||||
marginTop: '4px',
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
zIndex: '9999999',
|
||||
minWidth: '180px',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '12px',
|
||||
});
|
||||
picker.style.cssText = [
|
||||
'background: #161b22', 'border: 1px solid #30363d',
|
||||
'border-radius: 6px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)',
|
||||
'min-width: 180px', 'max-height: 200px', 'overflow-y: auto',
|
||||
"font-family: 'JetBrains Mono', monospace", 'font-size: 12px',
|
||||
].join('; ');
|
||||
|
||||
for (const [id, entry] of candidates) {
|
||||
const row = document.createElement('div');
|
||||
row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`;
|
||||
Object.assign(row.style, {
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
color: '#c9d1d9',
|
||||
borderBottom: '1px solid #21262d',
|
||||
});
|
||||
const label = entry.title + (/* user hint */ '');
|
||||
row.textContent = label;
|
||||
row.style.cssText = [
|
||||
'padding: 8px 12px', 'cursor: pointer', 'color: #c9d1d9',
|
||||
'border-bottom: 1px solid #21262d',
|
||||
].join('; ');
|
||||
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
|
||||
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
|
||||
row.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
picker.remove();
|
||||
const credResp = await chrome.runtime.sendMessage({
|
||||
type: 'get_credentials',
|
||||
id,
|
||||
});
|
||||
if (credResp?.ok) {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'fill_credentials',
|
||||
username: credResp.data.username,
|
||||
password: credResp.data.password,
|
||||
});
|
||||
}
|
||||
closeOverlay();
|
||||
await handleSingleCandidate(id);
|
||||
});
|
||||
picker.appendChild(row);
|
||||
}
|
||||
|
||||
anchor.parentElement?.appendChild(picker);
|
||||
root.appendChild(picker);
|
||||
|
||||
// Close picker on outside click.
|
||||
const closeHandler = (e: MouseEvent) => {
|
||||
if (!picker.contains(e.target as Node) && e.target !== anchor) {
|
||||
picker.remove();
|
||||
// Close picker on outside click (scoped to document; shadow root blocks
|
||||
// composedPath for closed mode but the host element still shows up).
|
||||
const closeHandler = (e: MouseEvent): void => {
|
||||
if (e.target !== host) {
|
||||
closeOverlay();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
}
|
||||
|
||||
/// TOFU origin-ack hint: credentials exist for this host but the user has
|
||||
/// never explicitly acknowledged autofill here. Instruct them to open
|
||||
/// relicario to confirm — we do not (and cannot) fill until ack-autofill
|
||||
/// has been called from the popup.
|
||||
function showAckHint(hostname: string): void {
|
||||
closeOverlay();
|
||||
const surface = createShadowHost();
|
||||
currentOverlay = surface;
|
||||
const { host, root } = surface;
|
||||
|
||||
host.style.cssText = [
|
||||
'position: fixed', 'top: 16px', 'right: 16px',
|
||||
'z-index: 2147483647',
|
||||
].join('; ');
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = [
|
||||
'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace',
|
||||
'font-size: 12px', 'color: #c9d1d9', 'background: #161b22',
|
||||
'border: 1px solid #30363d', 'border-radius: 6px',
|
||||
'padding: 10px 14px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)',
|
||||
'max-width: 320px', 'line-height: 1.5',
|
||||
].join('; ');
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #58a6ff;';
|
||||
title.textContent = 'relicario';
|
||||
hint.appendChild(title);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.appendChild(document.createTextNode('First autofill on '));
|
||||
const hostSpan = document.createElement('strong');
|
||||
hostSpan.textContent = hostname;
|
||||
body.appendChild(hostSpan);
|
||||
body.appendChild(document.createTextNode(' — open relicario to confirm.'));
|
||||
hint.appendChild(body);
|
||||
|
||||
const close = document.createElement('div');
|
||||
close.textContent = '✕';
|
||||
close.style.cssText = [
|
||||
'position: absolute', 'top: 6px', 'right: 8px',
|
||||
'cursor: pointer', 'color: #8b949e', 'font-size: 14px',
|
||||
].join('; ');
|
||||
close.addEventListener('click', closeOverlay);
|
||||
hint.style.position = 'relative';
|
||||
hint.appendChild(close);
|
||||
|
||||
root.appendChild(hint);
|
||||
|
||||
// Auto-dismiss after 8 seconds
|
||||
setTimeout(() => {
|
||||
if (currentOverlay === surface) closeOverlay();
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
37
extension/src/content/shadow.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/// Closed Shadow DOM host helper.
|
||||
///
|
||||
/// All in-page UI (capture prompt, autofill icon, candidate picker, TOFU
|
||||
/// banner) mounts into a closed-mode ShadowRoot so the host page cannot
|
||||
/// read or mutate the overlay via document.querySelector / DOM APIs. The
|
||||
/// returned ShadowSurface provides {host, root, destroy} for callers that
|
||||
/// want to populate the root, position the host, and tear everything down.
|
||||
|
||||
export interface ShadowSurface {
|
||||
/// The host <div> that's appended to document.body. Style/position this
|
||||
/// from the caller (position: fixed, z-index, transform, etc.).
|
||||
host: HTMLDivElement;
|
||||
/// Closed-mode ShadowRoot. Populate via textContent / appendChild —
|
||||
/// NEVER innerHTML, NEVER insertAdjacentHTML. Treat any caller-supplied
|
||||
/// string (hostname, username) as untrusted.
|
||||
root: ShadowRoot;
|
||||
/// Remove the host from the DOM and drop all references.
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/// Create a closed Shadow DOM host attached to document.body.
|
||||
///
|
||||
/// Callers are responsible for positioning `host` and filling `root`.
|
||||
export function createShadowHost(): ShadowSurface {
|
||||
const host = document.createElement('div');
|
||||
// Reset host-side styling so page CSS cannot leak in/out via inheritance.
|
||||
host.style.all = 'initial';
|
||||
const root = host.attachShadow({ mode: 'closed' });
|
||||
document.body.appendChild(host);
|
||||
return {
|
||||
host,
|
||||
root,
|
||||
destroy: () => {
|
||||
host.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { ManifestEntry } from '../../shared/types';
|
||||
|
||||
let totpInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stopTotpTimer(): void {
|
||||
if (totpInterval !== null) {
|
||||
clearInterval(totpInterval);
|
||||
totpInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// Fallback for older browsers.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEntryDetail(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const entry = state.selectedEntry;
|
||||
const id = state.selectedId;
|
||||
if (!entry || !id) {
|
||||
navigate('list');
|
||||
return;
|
||||
}
|
||||
|
||||
stopTotpTimer();
|
||||
|
||||
let html = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(entry.name)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// URL
|
||||
if (entry.url) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">url</div>
|
||||
<div class="field-value">${escapeHtml(entry.url)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Username
|
||||
if (entry.username) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">username</div>
|
||||
<div class="field-value" id="username-val">${escapeHtml(entry.username)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Password (masked by default)
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">password</div>
|
||||
<div class="field-value" id="password-val" style="cursor:pointer;">
|
||||
<span id="password-display">********</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TOTP
|
||||
if (entry.totp_secret) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">totp</div>
|
||||
<div class="totp-code" id="totp-code">------</div>
|
||||
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (entry.notes) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">notes</div>
|
||||
<div class="field-value">${escapeHtml(entry.notes)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Group
|
||||
if (entry.group) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">group</div>
|
||||
<div class="field-value">${escapeHtml(entry.group)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Metadata
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="muted">updated ${escapeHtml(entry.updated_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Key hints
|
||||
html += `
|
||||
<div class="keyhints">
|
||||
<span><kbd>c</kbd> copy user</span>
|
||||
<span><kbd>p</kbd> copy pass</span>
|
||||
${entry.totp_secret ? '<span><kbd>t</kbd> copy totp</span>' : ''}
|
||||
<span><kbd>f</kbd> autofill</span>
|
||||
<span><kbd>e</kbd> edit</span>
|
||||
<span><kbd>d</kbd> delete</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = html;
|
||||
|
||||
// --- Password toggle ---
|
||||
let passwordVisible = false;
|
||||
const passwordDisplay = document.getElementById('password-display')!;
|
||||
const passwordVal = document.getElementById('password-val')!;
|
||||
passwordVal?.addEventListener('click', () => {
|
||||
passwordVisible = !passwordVisible;
|
||||
passwordDisplay.textContent = passwordVisible ? entry.password : '********';
|
||||
});
|
||||
|
||||
// --- Back button ---
|
||||
document.getElementById('back-btn')?.addEventListener('click', goBack);
|
||||
|
||||
// --- TOTP timer ---
|
||||
if (entry.totp_secret) {
|
||||
refreshTotp(id);
|
||||
totpInterval = setInterval(() => refreshTotp(id), 1000);
|
||||
}
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input.
|
||||
if ((e.target as HTMLElement).tagName === 'INPUT') return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
document.removeEventListener('keydown', handler);
|
||||
goBack();
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
if (entry.username) await copyToClipboard(entry.username);
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
await copyToClipboard(entry.password);
|
||||
break;
|
||||
|
||||
case 't':
|
||||
if (entry.totp_secret) {
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
if (codeEl) await copyToClipboard(codeEl.textContent ?? '');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'f': {
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials',
|
||||
username: entry.username ?? '',
|
||||
password: entry.password,
|
||||
});
|
||||
if (!resp.ok) setState({ error: resp.error });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'e':
|
||||
document.removeEventListener('keydown', handler);
|
||||
stopTotpTimer();
|
||||
navigate('edit');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
showDeleteConfirm(id, entry.name, handler);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
async function refreshTotp(id: string): Promise<void> {
|
||||
const resp = await sendMessage({ type: 'get_totp', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { code: string; remaining_seconds: number };
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const barEl = document.getElementById('totp-bar-fill');
|
||||
if (codeEl) codeEl.textContent = data.code;
|
||||
if (barEl) barEl.style.width = `${(data.remaining_seconds / 30) * 100}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
stopTotpTimer();
|
||||
// Reload the entry list.
|
||||
sendMessage({ type: 'list_entries' }).then(resp => {
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { entries: Array<[string, ManifestEntry]> };
|
||||
navigate('list', {
|
||||
entries: data.entries,
|
||||
selectedId: null,
|
||||
selectedEntry: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
|
||||
<button class="btn" id="cancel-delete">cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">delete</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('cancel-delete')?.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-delete')?.addEventListener('click', async () => {
|
||||
overlay.remove();
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'delete_entry', id });
|
||||
if (resp.ok) {
|
||||
document.removeEventListener('keydown', parentHandler);
|
||||
stopTotpTimer();
|
||||
goBack();
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/// Entry form — add or edit an entry.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { Entry, ManifestEntry } from '../../shared/types';
|
||||
|
||||
export function renderEntryForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
const state = getState();
|
||||
const existing = mode === 'edit' ? state.selectedEntry : null;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new entry' : 'edit entry'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-name">name *</label>
|
||||
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(existing?.password ?? '')}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(existing?.group ?? '')}" placeholder="work">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(existing?.notes ?? '')}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Generate password ---
|
||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||
const resp = await sendMessage({ type: 'generate_password', length: 24 });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { password: string };
|
||||
const pwInput = document.getElementById('f-password') as HTMLInputElement;
|
||||
pwInput.value = data.password;
|
||||
pwInput.type = 'text'; // Show generated password.
|
||||
}
|
||||
});
|
||||
|
||||
// --- Cancel ---
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
|
||||
navigate('detail');
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Save ---
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
const name = (document.getElementById('f-name') as HTMLInputElement).value.trim();
|
||||
const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined;
|
||||
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined;
|
||||
const password = (document.getElementById('f-password') as HTMLInputElement).value;
|
||||
const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined;
|
||||
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined;
|
||||
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined;
|
||||
|
||||
if (!name) {
|
||||
setState({ error: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setState({ error: 'Password is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const entry: Entry = {
|
||||
name,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
notes,
|
||||
totp_secret,
|
||||
group,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
|
||||
let resp;
|
||||
if (mode === 'add') {
|
||||
resp = await sendMessage({ type: 'add_entry', entry });
|
||||
} else {
|
||||
resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry });
|
||||
}
|
||||
|
||||
if (resp.ok) {
|
||||
// Refresh entries and go to list.
|
||||
const listResp = await sendMessage({ type: 'list_entries' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||
navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null });
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Escape to cancel ---
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
if (mode === 'edit' && state.selectedId && state.selectedEntry) {
|
||||
navigate('detail');
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
// Focus the name field.
|
||||
(document.getElementById('f-name') as HTMLInputElement)?.focus();
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { ManifestEntry } from '../../shared/types';
|
||||
|
||||
/// Extract the domain from a URL for display.
|
||||
function domainOf(url: string | undefined): string {
|
||||
if (!url) return '';
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive unique group names from the current entries.
|
||||
function getGroups(entries: Array<[string, ManifestEntry]>): string[] {
|
||||
const groups = new Set<string>();
|
||||
for (const [, e] of entries) {
|
||||
if (e.group) groups.add(e.group);
|
||||
}
|
||||
return Array.from(groups).sort();
|
||||
}
|
||||
|
||||
export function renderEntryList(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const groups = getGroups(state.entries);
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
const groupTabsHtml = groups.length > 0
|
||||
? `<div class="group-tabs">
|
||||
<button class="group-tab ${!state.activeGroup ? 'active' : ''}" data-group="">all</button>
|
||||
${groups.map(g =>
|
||||
`<button class="group-tab ${state.activeGroup === g ? 'active' : ''}" data-group="${escapeHtml(g)}">${escapeHtml(g)}</button>`
|
||||
).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const entriesHtml = filtered.length > 0
|
||||
? filtered.map(([id, e], i) => `
|
||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||
<span class="entry-name">${escapeHtml(e.name)}</span>
|
||||
<span class="entry-meta">${escapeHtml(e.username ?? '')}${e.username && e.url ? ' · ' : ''}${escapeHtml(domainOf(e.url))}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty">no entries</div>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||||
</div>
|
||||
${groupTabsHtml}
|
||||
<div class="entry-list" id="entry-list">
|
||||
${entriesHtml}
|
||||
</div>
|
||||
<div class="keyhints">
|
||||
<span><kbd>/</kbd> search</span>
|
||||
<span><kbd>+</kbd> add</span>
|
||||
<span><kbd>↑↓</kbd> nav</span>
|
||||
<span><kbd>Enter</kbd> open</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Event listeners ---
|
||||
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
searchInput?.addEventListener('input', () => {
|
||||
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
|
||||
});
|
||||
|
||||
// Group tab clicks.
|
||||
const groupTabs = app.querySelectorAll('.group-tab');
|
||||
groupTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const group = (tab as HTMLElement).dataset.group || null;
|
||||
setState({ activeGroup: group, selectedIndex: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// Entry row clicks.
|
||||
const rows = app.querySelectorAll('.entry-row');
|
||||
rows.forEach(row => {
|
||||
row.addEventListener('click', async () => {
|
||||
const id = (row as HTMLElement).dataset.id!;
|
||||
await openEntry(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation.
|
||||
document.addEventListener('keydown', handleListKeydown);
|
||||
|
||||
// Focus search on / key (unless already focused).
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
async function openEntry(id: string): Promise<void> {
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'get_entry', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { entry: import('../../shared/types').Entry };
|
||||
navigate('detail', {
|
||||
selectedId: id,
|
||||
selectedEntry: data.entry,
|
||||
});
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the visible (filtered) entry list from current state.
|
||||
function getFilteredEntries(): Array<[string, ManifestEntry]> {
|
||||
const state = getState();
|
||||
let filtered = state.entries;
|
||||
if (state.activeGroup) {
|
||||
const g = state.activeGroup.toLowerCase();
|
||||
filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g);
|
||||
}
|
||||
if (state.searchQuery) {
|
||||
const q = state.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(([, e]) => {
|
||||
if (e.name.toLowerCase().includes(q)) return true;
|
||||
if (e.url?.toLowerCase().includes(q)) return true;
|
||||
if (e.username?.toLowerCase().includes(q)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
filtered.sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function handleListKeydown(e: KeyboardEvent): void {
|
||||
const state = getState();
|
||||
const target = e.target as HTMLElement;
|
||||
const isSearch = target.id === 'search-input';
|
||||
|
||||
if (e.key === '/' && !isSearch) {
|
||||
e.preventDefault();
|
||||
(document.getElementById('search-input') as HTMLInputElement)?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '+' && !isSearch) {
|
||||
e.preventDefault();
|
||||
navigate('add');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const max = Math.max(filtered.length - 1, 0);
|
||||
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !isSearch) {
|
||||
e.preventDefault();
|
||||
if (filtered[state.selectedIndex]) {
|
||||
openEntry(filtered[state.selectedIndex][0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
374
extension/src/popup/components/item-detail.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/// Typed-item detail view — dispatches on `item.type`. Slice 6 delivers
|
||||
/// full Login parity; all other types show a "coming soon" placeholder.
|
||||
///
|
||||
/// Autofill uses the (capturedTabId, capturedUrl) pair snapshotted at
|
||||
/// popup-open (see PopupState + router/popup-only.ts#handleFillCredentials)
|
||||
/// so the SW can reject the fill if the tab navigated.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types';
|
||||
|
||||
let totpInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stopTotpTimer(): void {
|
||||
if (totpInterval !== null) {
|
||||
clearInterval(totpInterval);
|
||||
totpInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// Fallback for older browsers.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderItemDetail(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const item = state.selectedItem;
|
||||
if (!item) {
|
||||
navigate('list');
|
||||
return;
|
||||
}
|
||||
|
||||
stopTotpTimer();
|
||||
|
||||
if (item.type === 'login') {
|
||||
renderLogin(app, item);
|
||||
} else {
|
||||
renderComingSoon(app, item);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Login detail ------------------------------------------------------
|
||||
|
||||
function renderLogin(app: HTMLElement, item: Item): void {
|
||||
const core = item.core as (LoginCore & { type: 'login' });
|
||||
const hasTotp = core.totp !== undefined;
|
||||
|
||||
let html = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(item.title)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (core.url) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">url</div>
|
||||
<div class="field-value"><a href="${escapeHtml(core.url)}" target="_blank" rel="noopener noreferrer" style="color:#58a6ff; text-decoration:none;">${escapeHtml(core.url)}</a></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (core.username) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">username</div>
|
||||
<div class="field-value" id="username-val" style="cursor:pointer;" title="click to copy">${escapeHtml(core.username)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">password</div>
|
||||
<div class="field-value" id="password-val" style="cursor:pointer;" title="click to toggle">
|
||||
<span id="password-display">********</span>
|
||||
<button class="btn" id="password-copy" style="font-size:10px; margin-left:8px; padding:2px 6px;">copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (hasTotp) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">totp</div>
|
||||
<div class="totp-code" id="totp-code">------</div>
|
||||
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (item.notes) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">notes</div>
|
||||
<div class="field-value" style="white-space:pre-wrap;">${escapeHtml(item.notes)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (item.group) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">group</div>
|
||||
<div class="field-value">${escapeHtml(item.group)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="muted">modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class="form-actions" style="padding:8px 12px;">
|
||||
<button class="btn btn-primary" id="fill-btn">autofill</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn btn-danger" id="trash-btn" style="margin-left:auto;">trash</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += `
|
||||
<div class="keyhints">
|
||||
<span><kbd>c</kbd> copy user</span>
|
||||
<span><kbd>p</kbd> copy pass</span>
|
||||
${hasTotp ? '<span><kbd>t</kbd> copy totp</span>' : ''}
|
||||
<span><kbd>f</kbd> autofill</span>
|
||||
<span><kbd>e</kbd> edit</span>
|
||||
<span><kbd>d</kbd> trash</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = html;
|
||||
|
||||
// --- Password toggle ---
|
||||
let passwordVisible = false;
|
||||
const passwordDisplay = document.getElementById('password-display');
|
||||
const passwordVal = document.getElementById('password-val');
|
||||
const password = core.password ?? '';
|
||||
passwordVal?.addEventListener('click', (e) => {
|
||||
// Ignore clicks originating on the copy button.
|
||||
if ((e.target as HTMLElement).id === 'password-copy') return;
|
||||
passwordVisible = !passwordVisible;
|
||||
if (passwordDisplay) passwordDisplay.textContent = passwordVisible ? password : '********';
|
||||
});
|
||||
document.getElementById('password-copy')?.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(password);
|
||||
});
|
||||
|
||||
if (core.username) {
|
||||
document.getElementById('username-val')?.addEventListener('click', async () => {
|
||||
await copyToClipboard(core.username ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', goBack);
|
||||
|
||||
document.getElementById('fill-btn')?.addEventListener('click', async () => {
|
||||
const { capturedTabId, capturedUrl } = getState();
|
||||
if (capturedTabId === null) {
|
||||
setState({ error: 'No active tab captured' });
|
||||
return;
|
||||
}
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials',
|
||||
id: item.id,
|
||||
capturedTabId,
|
||||
capturedUrl,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState({ error: resp.error });
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
});
|
||||
|
||||
document.getElementById('edit-btn')?.addEventListener('click', () => {
|
||||
document.removeEventListener('keydown', handler);
|
||||
stopTotpTimer();
|
||||
navigate('edit');
|
||||
});
|
||||
|
||||
document.getElementById('trash-btn')?.addEventListener('click', () => {
|
||||
showDeleteConfirm(item.id, item.title, handler);
|
||||
});
|
||||
|
||||
// --- TOTP timer ---
|
||||
if (hasTotp) {
|
||||
void refreshTotp(item.id);
|
||||
totpInterval = setInterval(() => { void refreshTotp(item.id); }, 1000);
|
||||
}
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
// Bail if the user is typing into any editable field — don't steal
|
||||
// printable keystrokes meant for an input/textarea/contenteditable element.
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
const tag = t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
document.removeEventListener('keydown', handler);
|
||||
goBack();
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
if (core.username) await copyToClipboard(core.username);
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
await copyToClipboard(password);
|
||||
break;
|
||||
|
||||
case 't':
|
||||
if (hasTotp) {
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
if (codeEl) await copyToClipboard(codeEl.textContent ?? '');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'f': {
|
||||
const { capturedTabId, capturedUrl } = getState();
|
||||
if (capturedTabId === null) {
|
||||
setState({ error: 'No active tab captured' });
|
||||
break;
|
||||
}
|
||||
const resp = await sendMessage({
|
||||
type: 'fill_credentials',
|
||||
id: item.id,
|
||||
capturedTabId,
|
||||
capturedUrl,
|
||||
});
|
||||
if (!resp.ok) setState({ error: resp.error });
|
||||
else window.close();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'e':
|
||||
document.removeEventListener('keydown', handler);
|
||||
stopTotpTimer();
|
||||
navigate('edit');
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
e.preventDefault();
|
||||
showDeleteConfirm(item.id, item.title, handler);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
async function refreshTotp(id: ItemId): Promise<void> {
|
||||
const resp = await sendMessage({ type: 'get_totp', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { code: string; expires_at: number };
|
||||
const codeEl = document.getElementById('totp-code');
|
||||
const barEl = document.getElementById('totp-bar-fill');
|
||||
if (codeEl) codeEl.textContent = data.code;
|
||||
if (barEl) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = Math.max(0, data.expires_at - now);
|
||||
// Period is 30 by default; compute ratio against 30.
|
||||
barEl.style.width = `${(remaining / 30) * 100}%`;
|
||||
}
|
||||
}
|
||||
// Suppress unused warning; TotpConfig referenced for typing only below.
|
||||
void ({} as TotpConfig);
|
||||
}
|
||||
|
||||
// --- Coming-soon for non-login types -----------------------------------
|
||||
|
||||
function renderComingSoon(app: HTMLElement, item: Item): void {
|
||||
app.innerHTML = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(item.title)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
<div class="pad" style="text-align:center; padding:32px 16px;">
|
||||
<div style="font-size:32px; margin-bottom:12px;">${typeEmoji(item.type)}</div>
|
||||
<div style="font-size:14px; color:#c9d1d9; margin-bottom:4px;">${escapeHtml(item.type.replace('_', ' '))}</div>
|
||||
<p class="muted">read/write for this type is coming in a later slice.</p>
|
||||
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', goBack);
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handler);
|
||||
goBack();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
function typeEmoji(t: Item['type']): string {
|
||||
switch (t) {
|
||||
case 'login': return '🔑';
|
||||
case 'secure_note': return '📝';
|
||||
case 'identity': return '🪪';
|
||||
case 'card': return '💳';
|
||||
case 'key': return '🗝';
|
||||
case 'document': return '📄';
|
||||
case 'totp': return '⏱';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared helpers ----------------------------------------------------
|
||||
|
||||
function goBack(): void {
|
||||
stopTotpTimer();
|
||||
// Reload the item list.
|
||||
void sendMessage({ type: 'list_items' }).then(resp => {
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', {
|
||||
entries: data.items,
|
||||
selectedId: null,
|
||||
selectedItem: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteConfirm(id: ItemId, title: string, parentHandler: (e: KeyboardEvent) => void): void {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<p>Trash <strong>${escapeHtml(title)}</strong>?</p>
|
||||
<button class="btn" id="cancel-delete">cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">trash</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('cancel-delete')?.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-delete')?.addEventListener('click', async () => {
|
||||
overlay.remove();
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'delete_item', id });
|
||||
if (resp.ok) {
|
||||
document.removeEventListener('keydown', parentHandler);
|
||||
stopTotpTimer();
|
||||
goBack();
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
});
|
||||
}
|
||||
281
extension/src/popup/components/item-form.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/// Typed-item add/edit form. Slice 6 ships full Login parity; other
|
||||
/// types show a coming-soon placeholder (use the CLI for now).
|
||||
///
|
||||
/// Carry-forward from Slice 5 review M3: on edit, trashed_at is
|
||||
/// explicitly reset to undefined so stale trash state cannot survive an
|
||||
/// edit. (The capture path already uses spread + fetched item; this
|
||||
/// popup flow uses state.selectedItem.)
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type {
|
||||
Item, ItemId, ItemType, ManifestEntry, LoginCore, TotpConfig,
|
||||
} from '../../shared/types';
|
||||
import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
|
||||
import { base32Decode, base32Encode } from '../../shared/base32';
|
||||
|
||||
// Which types support add/edit in Slice 6.
|
||||
function isEditableType(t: ItemType): boolean {
|
||||
return t === 'login';
|
||||
}
|
||||
|
||||
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
const state = getState();
|
||||
const existing = mode === 'edit' ? state.selectedItem : null;
|
||||
|
||||
// Determine the type we're editing/creating. Add defaults to login.
|
||||
const type: ItemType = existing?.type ?? 'login';
|
||||
|
||||
if (!isEditableType(type)) {
|
||||
renderComingSoon(app, type);
|
||||
return;
|
||||
}
|
||||
|
||||
renderLoginForm(app, mode, existing);
|
||||
}
|
||||
|
||||
// --- Coming-soon -------------------------------------------------------
|
||||
|
||||
function renderComingSoon(app: HTMLElement, type: ItemType): void {
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${escapeHtml(type.replace('_', ' '))}</div>
|
||||
<p class="muted">editing ${escapeHtml(type)} items is coming in a later slice.</p>
|
||||
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handler);
|
||||
navigate('list');
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
// --- Login add/edit ----------------------------------------------------
|
||||
|
||||
/// Encode TotpConfig secret bytes back to a base32 display string.
|
||||
function totpSecretToBase32(totp: TotpConfig | undefined): string {
|
||||
if (!totp) return '';
|
||||
return base32Encode(new Uint8Array(totp.secret));
|
||||
}
|
||||
|
||||
function renderLoginForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
const state = getState();
|
||||
const existingCore = (existing?.core.type === 'login')
|
||||
? (existing.core as LoginCore & { type: 'login' })
|
||||
: null;
|
||||
|
||||
const title = existing?.title ?? '';
|
||||
const url = existingCore?.url ?? '';
|
||||
const username = existingCore?.username ?? '';
|
||||
const password = existingCore?.password ?? '';
|
||||
const totpStr = totpSecretToBase32(existingCore?.totp);
|
||||
const group = existing?.group ?? '';
|
||||
const notes = existing?.notes ?? '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Generate password ---
|
||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||
const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { password: string };
|
||||
const pwInput = document.getElementById('f-password') as HTMLInputElement;
|
||||
pwInput.value = data.password;
|
||||
pwInput.type = 'text'; // Show generated password.
|
||||
} else {
|
||||
setState({ error: resp.error });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Cancel ---
|
||||
document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode));
|
||||
|
||||
// --- Save ---
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveLogin(mode, existing);
|
||||
});
|
||||
|
||||
// --- Escape to cancel ---
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
goBack(mode);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
// Focus the title field.
|
||||
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||
}
|
||||
|
||||
function goBack(mode: 'add' | 'edit'): void {
|
||||
const s = getState();
|
||||
if (mode === 'edit' && s.selectedId && s.selectedItem) {
|
||||
navigate('detail');
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a URL input so the Rust-side `url::Url::parse` accepts it.
|
||||
///
|
||||
/// Prepends `https://` when the input looks like a bare host (no scheme),
|
||||
/// then validates via the JS URL constructor. Returns { ok, value, error }.
|
||||
function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } {
|
||||
if (!raw) return { ok: true, value: '' };
|
||||
const trimmed = raw.trim();
|
||||
// If it already has a scheme, pass through. Otherwise assume https://.
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
// url::Url rejects schemes without an authority (host). Require a host.
|
||||
if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' };
|
||||
return { ok: true, value: u.toString() };
|
||||
} catch {
|
||||
return { ok: false, error: 'URL is not valid — try something like https://example.com' };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||
const state = getState();
|
||||
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim();
|
||||
const password = (document.getElementById('f-password') as HTMLInputElement).value;
|
||||
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
|
||||
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim();
|
||||
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value;
|
||||
|
||||
if (!title) {
|
||||
setState({ error: 'Title is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const urlResult = normalizeUrl(rawUrl);
|
||||
if (!urlResult.ok) {
|
||||
setState({ error: urlResult.error });
|
||||
return;
|
||||
}
|
||||
const url = urlResult.value;
|
||||
|
||||
let totp: TotpConfig | undefined;
|
||||
if (totpStr) {
|
||||
try {
|
||||
const bytes = base32Decode(totpStr);
|
||||
totp = {
|
||||
secret: Array.from(bytes),
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
};
|
||||
} catch (err) {
|
||||
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const core: LoginCore & { type: 'login' } = {
|
||||
type: 'login',
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
url: url || undefined,
|
||||
totp,
|
||||
};
|
||||
|
||||
// Build the Item. On edit we preserve id/created/tags/favorite/sections/
|
||||
// attachments/field_history from the existing item, but we EXPLICITLY
|
||||
// set trashed_at: undefined — never preserve stale trash state through
|
||||
// an edit (carry-forward from Slice 5 review M3).
|
||||
const item: Item = {
|
||||
id: existing?.id ?? '', // SW fills in for add_item.
|
||||
title,
|
||||
type: 'login',
|
||||
tags: existing?.tags ?? [],
|
||||
favorite: existing?.favorite ?? false,
|
||||
group: group || undefined,
|
||||
notes: notes || undefined,
|
||||
created: existing?.created ?? now,
|
||||
modified: now,
|
||||
trashed_at: undefined,
|
||||
core,
|
||||
sections: existing?.sections ?? [],
|
||||
attachments: existing?.attachments ?? [],
|
||||
field_history: existing?.field_history ?? {},
|
||||
};
|
||||
|
||||
setState({ loading: true, error: null });
|
||||
|
||||
let resp;
|
||||
if (mode === 'add') {
|
||||
resp = await sendMessage({ type: 'add_item', item });
|
||||
} else {
|
||||
if (!state.selectedId) {
|
||||
setState({ loading: false, error: 'Missing item id' });
|
||||
return;
|
||||
}
|
||||
resp = await sendMessage({ type: 'update_item', id: state.selectedId, item });
|
||||
}
|
||||
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
||||
} else {
|
||||
navigate('list');
|
||||
}
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
217
extension/src/popup/components/item-list.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/// Typed-item list view — toolbar (search, new, sync, lock, settings) +
|
||||
/// type-iconed rows. Clicking a row fetches the full Item and navigates
|
||||
/// to the detail view.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
|
||||
|
||||
/// Extract the display hostname from an icon_hint or fallback to the first tag.
|
||||
function metaLine(e: ManifestEntry): string {
|
||||
if (e.icon_hint) return e.icon_hint;
|
||||
if (e.tags.length > 0) return e.tags.join(', ');
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Emoji icon per item type. Placeholder until we ship real SVG icons.
|
||||
function typeIcon(t: ItemType): string {
|
||||
switch (t) {
|
||||
case 'login': return '🔑';
|
||||
case 'secure_note': return '📝';
|
||||
case 'identity': return '🪪';
|
||||
case 'card': return '💳';
|
||||
case 'key': return '🗝';
|
||||
case 'document': return '📄';
|
||||
case 'totp': return '⏱';
|
||||
}
|
||||
}
|
||||
|
||||
export function renderItemList(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
const rowsHtml = filtered.length > 0
|
||||
? filtered.map(([id, e], i) => `
|
||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}</span>
|
||||
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty">no items</div>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||||
</div>
|
||||
<div class="toolbar" style="display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid #21262d;">
|
||||
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
|
||||
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
||||
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
|
||||
</div>
|
||||
<div class="entry-list" id="item-list">
|
||||
${rowsHtml}
|
||||
</div>
|
||||
<div class="keyhints">
|
||||
<span><kbd>/</kbd> search</span>
|
||||
<span><kbd>+</kbd> new</span>
|
||||
<span><kbd>↑↓</kbd> nav</span>
|
||||
<span><kbd>Enter</kbd> open</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Event listeners ---
|
||||
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
|
||||
searchInput?.addEventListener('input', () => {
|
||||
setState({ searchQuery: searchInput.value, selectedIndex: 0 });
|
||||
});
|
||||
|
||||
document.getElementById('new-btn')?.addEventListener('click', () => navigate('add'));
|
||||
|
||||
document.getElementById('sync-btn')?.addEventListener('click', async () => {
|
||||
setState({ loading: true, error: null });
|
||||
const resp = await sendMessage({ type: 'sync' });
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
setState({ entries: data.items, loading: false });
|
||||
return;
|
||||
}
|
||||
setState({ loading: false, error: listResp.error });
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('lock-btn')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'lock' });
|
||||
navigate('locked');
|
||||
});
|
||||
|
||||
document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));
|
||||
|
||||
// Item row clicks.
|
||||
const rows = app.querySelectorAll('.entry-row');
|
||||
rows.forEach(row => {
|
||||
row.addEventListener('click', async () => {
|
||||
const id = (row as HTMLElement).dataset.id!;
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
await openItem(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation.
|
||||
document.addEventListener('keydown', handleListKeydown);
|
||||
|
||||
// Focus search on open.
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
async function openItem(id: ItemId): Promise<void> {
|
||||
setState({ loading: true });
|
||||
const resp = await sendMessage({ type: 'get_item', id });
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { item: Item };
|
||||
navigate('detail', {
|
||||
selectedId: id,
|
||||
selectedItem: data.item,
|
||||
});
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the visible (filtered) entry list from current state.
|
||||
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
|
||||
const state = getState();
|
||||
// Hide trashed items from the main list.
|
||||
let filtered = state.entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null);
|
||||
if (state.searchQuery) {
|
||||
const q = state.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(([, e]) => {
|
||||
if (e.title.toLowerCase().includes(q)) return true;
|
||||
if (e.icon_hint?.toLowerCase().includes(q)) return true;
|
||||
if (e.group?.toLowerCase().includes(q)) return true;
|
||||
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// True if the event target is an editable field (input/textarea/contenteditable).
|
||||
/// Global shortcut handlers should bail when the user is typing into a field —
|
||||
/// otherwise printable characters like "/" and "+" get eaten by the shortcut
|
||||
/// routing and never reach the input.
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
if (target.isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleListKeydown(e: KeyboardEvent): void {
|
||||
const state = getState();
|
||||
const target = e.target as HTMLElement;
|
||||
const isSearch = target.id === 'search-input';
|
||||
|
||||
// If the user is typing into any input/textarea (other than the list's own
|
||||
// search field, which we want to focus on "/" even from outside it), let the
|
||||
// keystroke through. The "/" shortcut below is specifically "jump to search
|
||||
// from the list," not "steal printable characters while typing."
|
||||
if (isEditableTarget(target) && !isSearch) {
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
window.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '/' && !isSearch) {
|
||||
e.preventDefault();
|
||||
(document.getElementById('search-input') as HTMLInputElement | null)?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === '+' && !isSearch) {
|
||||
e.preventDefault();
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
navigate('add');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = getFilteredEntries();
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const max = Math.max(filtered.length - 1, 0);
|
||||
setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !isSearch) {
|
||||
e.preventDefault();
|
||||
const selected = filtered[state.selectedIndex];
|
||||
if (selected) {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
void openItem(selected[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
document.removeEventListener('keydown', handleListKeydown);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Settings view — capture toggle, prompt style, and blacklist management.
|
||||
|
||||
import { sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { RelicarioSettings } from '../../shared/types';
|
||||
import type { DeviceSettings } from '../../shared/types';
|
||||
|
||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||
@@ -12,8 +12,8 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
sendMessage({ type: 'get_blacklist' }),
|
||||
]);
|
||||
|
||||
const settings: RelicarioSettings = settingsResp.ok
|
||||
? (settingsResp.data as { settings: RelicarioSettings }).settings
|
||||
const settings: DeviceSettings = settingsResp.ok
|
||||
? (settingsResp.data as { settings: DeviceSettings }).settings
|
||||
: { captureEnabled: false, captureStyle: 'bar' };
|
||||
|
||||
const blacklist: string[] = blacklistResp.ok
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/// Setup prompt — directs users to the full-page setup wizard.
|
||||
///
|
||||
/// The popup is too constrained for file pickers and multi-step forms
|
||||
/// (Chrome closes it when focus shifts). All real setup happens in
|
||||
/// setup.html, which pushes config to chrome.storage.local when done.
|
||||
|
||||
import { escapeHtml } from '../popup';
|
||||
|
||||
export function renderSetupWizard(app: HTMLElement): void {
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:24px;text-align:center;">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<div class="brand" style="font-size:16px;margin-bottom:4px;">relicario</div>
|
||||
<p class="secondary" style="margin-bottom:20px;">two-factor vault</p>
|
||||
|
||||
<p class="muted" style="margin-bottom:16px;font-size:11px;line-height:1.6;">
|
||||
No vault configured yet. Open the setup wizard to
|
||||
create a new vault or connect to an existing one.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-primary" id="open-setup-btn" style="width:100%;">
|
||||
open setup wizard
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('open-setup-btn')?.addEventListener('click', () => {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Unlock view — passphrase input with ENTER to submit.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { ManifestEntry } from '../../shared/types';
|
||||
import type { ItemId, ManifestEntry } from '../../shared/types';
|
||||
|
||||
export function renderUnlock(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
@@ -38,10 +38,10 @@ export function renderUnlock(app: HTMLElement): void {
|
||||
setState({ loading: true, error: null });
|
||||
const resp = await sendMessage({ type: 'unlock', passphrase });
|
||||
if (resp.ok) {
|
||||
const listResp = await sendMessage({ type: 'list_entries' });
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const data = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||
navigate('list', { entries: data.entries });
|
||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: data.items });
|
||||
} else {
|
||||
setState({ loading: false, error: listResp.error });
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
/// Navigation works by updating `currentState` and calling `render()`.
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type { ManifestEntry, Entry } from '../shared/types';
|
||||
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
||||
import { renderUnlock } from './components/unlock';
|
||||
import { renderEntryList } from './components/entry-list';
|
||||
import { renderEntryDetail } from './components/entry-detail';
|
||||
import { renderEntryForm } from './components/entry-form';
|
||||
import { renderSetupWizard } from './components/setup-wizard';
|
||||
import { renderItemList } from './components/item-list';
|
||||
import { renderItemDetail } from './components/item-detail';
|
||||
import { renderItemForm } from './components/item-form';
|
||||
import { renderSettings } from './components/settings';
|
||||
|
||||
// --- Escape HTML to prevent XSS ---
|
||||
@@ -21,30 +20,38 @@ export function escapeHtml(str: string): string {
|
||||
|
||||
// --- State ---
|
||||
|
||||
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
||||
|
||||
export interface PopupState {
|
||||
view: View;
|
||||
entries: Array<[string, ManifestEntry]>;
|
||||
selectedId: string | null;
|
||||
selectedEntry: Entry | null;
|
||||
entries: Array<[ItemId, ManifestEntry]>;
|
||||
selectedId: ItemId | null;
|
||||
selectedItem: Item | null;
|
||||
selectedIndex: number;
|
||||
searchQuery: string;
|
||||
activeGroup: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
// Captured tab snapshot taken at popup-open. Used by fill_credentials
|
||||
// to guard against TOCTOU navigation — the SW re-checks this URL's
|
||||
// hostname against the tab's live URL before forwarding fill_credentials
|
||||
// to the content script. See router/popup-only.ts#handleFillCredentials.
|
||||
capturedTabId: number | null;
|
||||
capturedUrl: string;
|
||||
}
|
||||
|
||||
let currentState: PopupState = {
|
||||
view: 'locked',
|
||||
entries: [],
|
||||
selectedId: null,
|
||||
selectedEntry: null,
|
||||
selectedItem: null,
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
capturedTabId: null,
|
||||
capturedUrl: '',
|
||||
};
|
||||
|
||||
export function getState(): PopupState {
|
||||
@@ -61,11 +68,44 @@ export function setState(partial: Partial<PopupState>): void {
|
||||
export function sendMessage(request: Request): Promise<Response> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||
if (response && !response.ok && response.error) {
|
||||
// Replace cryptic low-level errors with user-readable messages.
|
||||
response = { ok: false, error: humanizeError(response.error) };
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Translate cryptic Rust/serde/WASM error strings into messages a user
|
||||
/// can act on. Unknown errors pass through unchanged.
|
||||
export function humanizeError(err: string): string {
|
||||
// URL parse failures (Rust `url::Url::parse`) bubble up through serde
|
||||
// as `item json: ...`. Match the core phrasing.
|
||||
if (/relative URL without a base/i.test(err)) {
|
||||
return 'URL must start with https:// or http:// (e.g. https://example.com)';
|
||||
}
|
||||
if (/item json:/i.test(err)) {
|
||||
return 'Could not save item — one of the fields is in an invalid format.';
|
||||
}
|
||||
if (/settings json:/i.test(err)) {
|
||||
return 'Settings are in an invalid format — try reloading the extension.';
|
||||
}
|
||||
if (/vault_locked/i.test(err)) {
|
||||
return 'Vault is locked. Unlock and try again.';
|
||||
}
|
||||
if (/origin_mismatch/i.test(err)) {
|
||||
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
|
||||
}
|
||||
if (/unauthorized_sender/i.test(err)) {
|
||||
return 'This action is not allowed from here.';
|
||||
}
|
||||
if (/tab_navigated|captured_tab_gone/i.test(err)) {
|
||||
return 'The browser tab changed before the fill could complete — try again.';
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||
@@ -79,23 +119,20 @@ function render(): void {
|
||||
if (!app) return;
|
||||
|
||||
switch (currentState.view) {
|
||||
case 'setup':
|
||||
renderSetupWizard(app);
|
||||
break;
|
||||
case 'locked':
|
||||
renderUnlock(app);
|
||||
break;
|
||||
case 'list':
|
||||
renderEntryList(app);
|
||||
renderItemList(app);
|
||||
break;
|
||||
case 'detail':
|
||||
renderEntryDetail(app);
|
||||
renderItemDetail(app);
|
||||
break;
|
||||
case 'add':
|
||||
renderEntryForm(app, 'add');
|
||||
renderItemForm(app, 'add');
|
||||
break;
|
||||
case 'edit':
|
||||
renderEntryForm(app, 'edit');
|
||||
renderItemForm(app, 'edit');
|
||||
break;
|
||||
case 'settings':
|
||||
renderSettings(app);
|
||||
@@ -106,12 +143,20 @@ function render(): void {
|
||||
// --- Init ---
|
||||
|
||||
async function init(): Promise<void> {
|
||||
// Snapshot the active tab at popup-open — the fill path uses this
|
||||
// tabId/url pair so the SW can verify the tab hasn't navigated before
|
||||
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
currentState.capturedTabId = tab?.id ?? null;
|
||||
currentState.capturedUrl = tab?.url ?? '';
|
||||
|
||||
// Check if extension is configured.
|
||||
const setupResp = await sendMessage({ type: 'get_setup_state' });
|
||||
if (setupResp.ok) {
|
||||
const data = setupResp.data as { isConfigured: boolean };
|
||||
if (!data.isConfigured) {
|
||||
navigate('setup');
|
||||
await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -122,10 +167,10 @@ async function init(): Promise<void> {
|
||||
const data = unlockResp.data as { unlocked: boolean };
|
||||
if (data.unlocked) {
|
||||
// Load entries and go to list.
|
||||
const listResp = await sendMessage({ type: 'list_entries' });
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const listData = listResp.data as { entries: Array<[string, ManifestEntry]> };
|
||||
navigate('list', { entries: listData.entries });
|
||||
const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: listData.items });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
/// Background script entry point for the relicario browser extension.
|
||||
///
|
||||
/// In Chrome this runs as a service worker (MV3). In Firefox this runs
|
||||
/// as a persistent background script. WASM loading adapts automatically.
|
||||
///
|
||||
/// Loads the WASM module, manages vault state (master key, manifest, git host),
|
||||
/// and routes all messages from the popup and content scripts.
|
||||
/// Thin service-worker entry: loads WASM, constructs the router state, and
|
||||
/// forwards every message into router/index.route().
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type { Manifest, VaultConfig, SetupState, RelicarioSettings } from '../shared/types';
|
||||
import { DEFAULT_SETTINGS } from '../shared/types';
|
||||
import type { GitHost } from './git-host';
|
||||
import { createGitHost } from './git-host';
|
||||
import { base64ToUint8Array } from './git-host';
|
||||
import type { RouterState } from './router/index';
|
||||
import { route } from './router/index';
|
||||
import * as vault from './vault';
|
||||
|
||||
// --- State held in memory (cleared on lock or service worker restart) ---
|
||||
|
||||
let masterKey: Uint8Array | null = null;
|
||||
let manifest: Manifest | null = null;
|
||||
let gitHost: GitHost | null = null;
|
||||
let wasmReady = false;
|
||||
// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second
|
||||
const totpSecretCache: Map<string, string> = new Map();
|
||||
|
||||
// --- WASM initialization ---
|
||||
|
||||
// Chrome MV3 uses service workers which do NOT support dynamic import().
|
||||
// Firefox MV3 uses background scripts which DO support dynamic import().
|
||||
// We detect the environment at runtime and use the appropriate loading strategy.
|
||||
|
||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||
// @ts-ignore TS2307
|
||||
@@ -46,396 +23,48 @@ async function initWasm(): Promise<WasmModule> {
|
||||
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
|
||||
|
||||
if (isServiceWorker) {
|
||||
// Chrome: fetch WASM binary and instantiate synchronously
|
||||
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
|
||||
const wasmBytes = await wasmResponse.arrayBuffer();
|
||||
initSync({ module: new WebAssembly.Module(wasmBytes) });
|
||||
} else {
|
||||
// Firefox: background script — async init works
|
||||
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
||||
await initDefault(wasmUrl);
|
||||
}
|
||||
|
||||
vault.setWasm(wasmBindings);
|
||||
wasm = wasmBindings;
|
||||
wasmReady = true;
|
||||
return wasm;
|
||||
}
|
||||
|
||||
// --- Storage helpers ---
|
||||
|
||||
async function loadConfig(): Promise<VaultConfig | null> {
|
||||
const result = await chrome.storage.local.get('vaultConfig');
|
||||
return (result.vaultConfig as VaultConfig) ?? null;
|
||||
}
|
||||
|
||||
async function loadImageBase64(): Promise<string | null> {
|
||||
const result = await chrome.storage.local.get('imageBase64');
|
||||
return (result.imageBase64 as string) ?? null;
|
||||
}
|
||||
|
||||
async function loadSetupState(): Promise<SetupState> {
|
||||
const config = await loadConfig();
|
||||
const imageBase64 = await loadImageBase64();
|
||||
return {
|
||||
config,
|
||||
imageBase64,
|
||||
isConfigured: config !== null && imageBase64 !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Settings & blacklist helpers ---
|
||||
|
||||
async function loadSettings(): Promise<RelicarioSettings> {
|
||||
const result = await chrome.storage.local.get('relicarioSettings');
|
||||
return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
async function saveSettings(settings: RelicarioSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: settings });
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const result = await chrome.storage.local.get('captureBlacklist');
|
||||
return (result.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
function ensureGitHost(config: VaultConfig): GitHost {
|
||||
if (!gitHost) {
|
||||
gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
}
|
||||
return gitHost;
|
||||
}
|
||||
|
||||
// --- Message handler ---
|
||||
// Single router-state object shared by all messages for this SW instance.
|
||||
const state: RouterState = {
|
||||
manifest: null,
|
||||
gitHost: null,
|
||||
wasm: null,
|
||||
};
|
||||
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
|
||||
handleMessage(request)
|
||||
.then(sendResponse)
|
||||
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
|
||||
// Return true to indicate async response.
|
||||
return true;
|
||||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||||
(async () => {
|
||||
if (!state.wasm) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[relicario sw] initializing WASM on first message');
|
||||
state.wasm = await initWasm();
|
||||
}
|
||||
return route(request, state, sender);
|
||||
})()
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[relicario sw] ${request.type} -> error:`, r.error);
|
||||
}
|
||||
sendResponse(r);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[relicario sw] ${request.type} threw:`, err);
|
||||
sendResponse({ ok: false, error: err.message });
|
||||
});
|
||||
return true; // async response
|
||||
},
|
||||
);
|
||||
|
||||
async function handleMessage(req: Request): Promise<Response> {
|
||||
switch (req.type) {
|
||||
// --- Auth ---
|
||||
|
||||
case 'is_unlocked':
|
||||
return { ok: true, data: { unlocked: masterKey !== null } };
|
||||
|
||||
case 'unlock': {
|
||||
const w = await initWasm();
|
||||
const config = await loadConfig();
|
||||
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
||||
|
||||
const imageB64 = await loadImageBase64();
|
||||
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
||||
|
||||
const imageBytes = base64ToUint8Array(imageB64);
|
||||
const imageSecret = w.extract_image_secret(imageBytes);
|
||||
|
||||
const git = ensureGitHost(config);
|
||||
const meta = await vault.fetchVaultMeta(git);
|
||||
|
||||
const key = w.derive_master_key(
|
||||
req.passphrase,
|
||||
new Uint8Array(imageSecret),
|
||||
meta.salt,
|
||||
meta.paramsJson,
|
||||
);
|
||||
masterKey = new Uint8Array(key);
|
||||
|
||||
// Verify the key works by decrypting the manifest.
|
||||
manifest = await vault.fetchAndDecryptManifest(git, masterKey);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'lock':
|
||||
masterKey = null;
|
||||
manifest = null;
|
||||
totpSecretCache.clear();
|
||||
return { ok: true };
|
||||
|
||||
// --- Entries ---
|
||||
|
||||
case 'list_entries': {
|
||||
if (!manifest) return { ok: false, error: 'Vault is locked' };
|
||||
const entries = vault.listEntries(manifest, req.group);
|
||||
return { ok: true, data: { entries } };
|
||||
}
|
||||
|
||||
case 'get_entry': {
|
||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
||||
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
||||
return { ok: true, data: { entry } };
|
||||
}
|
||||
|
||||
case 'search_entries': {
|
||||
if (!manifest) return { ok: false, error: 'Vault is locked' };
|
||||
const entries = vault.searchEntries(manifest, req.query);
|
||||
return { ok: true, data: { entries } };
|
||||
}
|
||||
|
||||
case 'add_entry': {
|
||||
if (!masterKey || !gitHost || !manifest) {
|
||||
return { ok: false, error: 'Vault is locked' };
|
||||
}
|
||||
const w = await initWasm();
|
||||
const id = w.generate_entry_id();
|
||||
|
||||
await vault.encryptAndWriteEntry(
|
||||
gitHost, masterKey, id, req.entry,
|
||||
`add: ${req.entry.name}`,
|
||||
);
|
||||
|
||||
manifest.entries[id] = {
|
||||
name: req.entry.name,
|
||||
url: req.entry.url,
|
||||
username: req.entry.username,
|
||||
group: req.entry.group,
|
||||
updated_at: req.entry.updated_at,
|
||||
};
|
||||
|
||||
await vault.encryptAndWriteManifest(
|
||||
gitHost, masterKey, manifest,
|
||||
`manifest: add ${req.entry.name}`,
|
||||
);
|
||||
|
||||
return { ok: true, data: { id } };
|
||||
}
|
||||
|
||||
case 'update_entry': {
|
||||
if (!masterKey || !gitHost || !manifest) {
|
||||
return { ok: false, error: 'Vault is locked' };
|
||||
}
|
||||
|
||||
await vault.encryptAndWriteEntry(
|
||||
gitHost, masterKey, req.id, req.entry,
|
||||
`update: ${req.entry.name}`,
|
||||
);
|
||||
|
||||
manifest.entries[req.id] = {
|
||||
name: req.entry.name,
|
||||
url: req.entry.url,
|
||||
username: req.entry.username,
|
||||
group: req.entry.group,
|
||||
updated_at: req.entry.updated_at,
|
||||
};
|
||||
|
||||
await vault.encryptAndWriteManifest(
|
||||
gitHost, masterKey, manifest,
|
||||
`manifest: update ${req.entry.name}`,
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'delete_entry': {
|
||||
if (!masterKey || !gitHost || !manifest) {
|
||||
return { ok: false, error: 'Vault is locked' };
|
||||
}
|
||||
|
||||
const name = manifest.entries[req.id]?.name ?? req.id;
|
||||
await gitHost.deleteFile(`entries/${req.id}.enc`, `delete: ${name}`);
|
||||
|
||||
delete manifest.entries[req.id];
|
||||
|
||||
await vault.encryptAndWriteManifest(
|
||||
gitHost, masterKey, manifest,
|
||||
`manifest: delete ${name}`,
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- TOTP ---
|
||||
|
||||
case 'get_totp': {
|
||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
||||
const w = await initWasm();
|
||||
|
||||
// Use cached TOTP secret to avoid re-fetching the entry every second
|
||||
let totpSecret = totpSecretCache.get(req.id);
|
||||
if (!totpSecret) {
|
||||
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
||||
if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' };
|
||||
totpSecret = entry.totp_secret;
|
||||
totpSecretCache.set(req.id, totpSecret);
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const code = w.generate_totp(totpSecret, BigInt(now));
|
||||
const remaining = 30 - (now % 30);
|
||||
|
||||
return { ok: true, data: { code, remaining_seconds: remaining } };
|
||||
}
|
||||
|
||||
// --- Autofill ---
|
||||
|
||||
case 'get_autofill_candidates': {
|
||||
if (!manifest) return { ok: false, error: 'Vault is locked' };
|
||||
const candidates = vault.findByUrl(manifest, req.url);
|
||||
return { ok: true, data: { candidates } };
|
||||
}
|
||||
|
||||
case 'get_credentials': {
|
||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
||||
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
||||
return {
|
||||
ok: true,
|
||||
data: { username: entry.username ?? '', password: entry.password },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
|
||||
case 'sync': {
|
||||
if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' };
|
||||
// Re-fetch the manifest from the remote to pick up changes from other devices.
|
||||
manifest = await vault.fetchAndDecryptManifest(gitHost, masterKey);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
case 'get_setup_state': {
|
||||
const state = await loadSetupState();
|
||||
return { ok: true, data: state };
|
||||
}
|
||||
|
||||
case 'save_setup': {
|
||||
await chrome.storage.local.set({
|
||||
vaultConfig: req.config,
|
||||
imageBase64: req.imageBase64,
|
||||
});
|
||||
// Reset git host so it picks up new config on next use.
|
||||
gitHost = null;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Password generation ---
|
||||
|
||||
case 'generate_password': {
|
||||
const w = await initWasm();
|
||||
const password = w.generate_password(req.length);
|
||||
return { ok: true, data: { password } };
|
||||
}
|
||||
|
||||
// --- Content script fill (forwarded to active tab) ---
|
||||
|
||||
case 'fill_credentials': {
|
||||
// This is actually sent TO the content script, not FROM it.
|
||||
// The popup sends this to the service worker, which forwards it.
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (tab?.id) {
|
||||
await chrome.tabs.sendMessage(tab.id, {
|
||||
type: 'fill_credentials',
|
||||
username: req.username,
|
||||
password: req.password,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Settings & blacklist ---
|
||||
|
||||
case 'get_settings': {
|
||||
const settings = await loadSettings();
|
||||
return { ok: true, data: { settings } };
|
||||
}
|
||||
|
||||
case 'update_settings': {
|
||||
const current = await loadSettings();
|
||||
const updated = { ...current, ...req.settings };
|
||||
await saveSettings(updated);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_blacklist': {
|
||||
const blacklist = await loadBlacklist();
|
||||
return { ok: true, data: { blacklist } };
|
||||
}
|
||||
|
||||
case 'remove_blacklist': {
|
||||
const bl = await loadBlacklist();
|
||||
await saveBlacklist(bl.filter((h) => h !== req.hostname));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'blacklist_site': {
|
||||
const bl2 = await loadBlacklist();
|
||||
if (!bl2.includes(req.hostname)) {
|
||||
bl2.push(req.hostname);
|
||||
await saveBlacklist(bl2);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Credential capture ---
|
||||
|
||||
case 'check_credential': {
|
||||
// Skip if vault locked
|
||||
if (!masterKey || !gitHost || !manifest) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Skip if capture disabled
|
||||
const captureSettings = await loadSettings();
|
||||
if (!captureSettings.captureEnabled) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Skip if hostname blacklisted
|
||||
let checkHostname: string;
|
||||
try {
|
||||
checkHostname = new URL(req.url).hostname;
|
||||
} catch {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
const captureBlacklist = await loadBlacklist();
|
||||
if (captureBlacklist.includes(checkHostname)) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Search manifest by hostname
|
||||
const candidates = vault.findByUrl(manifest, req.url);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { ok: true, data: { action: 'save' } };
|
||||
}
|
||||
|
||||
// Check for matching username
|
||||
for (const [entryId, entry] of candidates) {
|
||||
if (entry.username === req.username) {
|
||||
// Same hostname + username — compare passwords
|
||||
try {
|
||||
const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, entryId);
|
||||
if (fullEntry.password === req.password) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
} else {
|
||||
return { ok: true, data: { action: 'update', entryId, entryName: entry.name } };
|
||||
}
|
||||
} catch {
|
||||
// If we can't decrypt, skip rather than error
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same hostname, different username — new account
|
||||
return { ok: true, data: { action: 'save' } };
|
||||
}
|
||||
|
||||
default:
|
||||
return { ok: false, error: `Unknown message type: ${(req as { type: string }).type}` };
|
||||
}
|
||||
}
|
||||
|
||||
524
extension/src/service-worker/router/__tests__/router.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// --- Mocks (must be declared before `route` is imported so the router's
|
||||
// `import * as vault` / `import * as session` resolve to these doubles) ---
|
||||
|
||||
// Partial mock: we override only the vault calls the new tests care about
|
||||
// (fetchAndDecryptItem / fetchAndDecryptSettings / encryptAndWriteSettings)
|
||||
// and let the real implementations of listItems / findByHostname / etc.
|
||||
// continue to run for the other tests that don't need mocks.
|
||||
vi.mock('../../vault', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../vault')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAndDecryptItem: vi.fn(),
|
||||
fetchAndDecryptSettings: vi.fn(),
|
||||
encryptAndWriteSettings: vi.fn(),
|
||||
encryptAndWriteItem: vi.fn(),
|
||||
encryptAndWriteManifest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../session', () => ({
|
||||
setCurrent: vi.fn(),
|
||||
getCurrent: vi.fn(),
|
||||
clearCurrent: vi.fn(),
|
||||
requireCurrent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { route, type RouterState } from '../index';
|
||||
import type { Request } from '../../../shared/messages';
|
||||
import type { Item } from '../../../shared/types';
|
||||
import * as vault from '../../vault';
|
||||
import * as session from '../../session';
|
||||
|
||||
// --- chrome.* shim ---
|
||||
|
||||
// @ts-expect-error test harness
|
||||
globalThis.chrome = {
|
||||
runtime: {
|
||||
id: 'relicario-test-id',
|
||||
getURL: (p: string) => `chrome-extension://relicario-test-id/${p}`,
|
||||
},
|
||||
storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } },
|
||||
tabs: { get: vi.fn(), sendMessage: vi.fn() },
|
||||
};
|
||||
|
||||
function makePopupSender(): chrome.runtime.MessageSender {
|
||||
return { url: `chrome-extension://relicario-test-id/popup.html`, id: 'relicario-test-id' };
|
||||
}
|
||||
|
||||
function makeSetupSender(): chrome.runtime.MessageSender {
|
||||
return { url: `chrome-extension://relicario-test-id/setup.html`, id: 'relicario-test-id' };
|
||||
}
|
||||
|
||||
function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.MessageSender {
|
||||
return {
|
||||
tab: { id: 42, url: pageUrl } as chrome.tabs.Tab,
|
||||
frameId: 0,
|
||||
id: 'relicario-test-id',
|
||||
};
|
||||
}
|
||||
|
||||
function makeExternalSender(): chrome.runtime.MessageSender {
|
||||
return { url: 'https://evil.example/', id: 'some-other-extension' };
|
||||
}
|
||||
|
||||
function makeState(): RouterState {
|
||||
return {
|
||||
manifest: { schema_version: 2, items: {} },
|
||||
gitHost: null,
|
||||
wasm: {
|
||||
// Stubs sufficient for the message types exercised by tests:
|
||||
new_item_id: () => 'fakeitemid0000ab',
|
||||
generate_password: () => 'PASSWORD',
|
||||
rate_passphrase: () => ({ score: 4, guesses_log10: 15 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Sender-check matrix ---
|
||||
|
||||
describe('router sender dispatch', () => {
|
||||
let state: RouterState;
|
||||
beforeEach(() => { state = makeState(); });
|
||||
|
||||
const popupOnlyMsgs: Request[] = [
|
||||
{ type: 'is_unlocked' },
|
||||
{ type: 'lock' },
|
||||
{ type: 'list_items' },
|
||||
{ type: 'generate_password', request: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' } } },
|
||||
{ type: 'rate_passphrase', passphrase: 'hunter2hunter2hunter2' },
|
||||
{ type: 'get_blacklist' },
|
||||
];
|
||||
|
||||
for (const msg of popupOnlyMsgs) {
|
||||
it(`accepts popup-only "${msg.type}" from popup`, async () => {
|
||||
const res = await route(msg, state, makePopupSender());
|
||||
expect(res).toMatchObject({ ok: true });
|
||||
});
|
||||
it(`rejects popup-only "${msg.type}" from content`, async () => {
|
||||
const res = await route(msg, state, makeContentSender());
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
it(`rejects popup-only "${msg.type}" from external`, async () => {
|
||||
const res = await route(msg, state, makeExternalSender());
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
}
|
||||
|
||||
it('accepts save_setup from popup', async () => {
|
||||
const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
|
||||
const res = await route(msg, state, makePopupSender());
|
||||
expect(res).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts save_setup from setup tab', async () => {
|
||||
const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
|
||||
const res = await route(msg, state, makeSetupSender());
|
||||
expect(res).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects save_setup from content', async () => {
|
||||
const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
|
||||
const res = await route(msg, state, makeContentSender());
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
|
||||
const contentMsgs: Request[] = [
|
||||
{ type: 'get_autofill_candidates' },
|
||||
{ type: 'blacklist_site' },
|
||||
];
|
||||
|
||||
for (const msg of contentMsgs) {
|
||||
it(`accepts content "${msg.type}" from top-frame content`, async () => {
|
||||
const res = await route(msg, state, makeContentSender());
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
it(`rejects content "${msg.type}" from popup`, async () => {
|
||||
const res = await route(msg, state, makePopupSender());
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
it(`rejects content "${msg.type}" from subframe`, async () => {
|
||||
const sender: chrome.runtime.MessageSender = { ...makeContentSender(), frameId: 3 };
|
||||
const res = await route(msg, state, sender);
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
it(`rejects content "${msg.type}" from external`, async () => {
|
||||
const res = await route(msg, state, makeExternalSender());
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects unknown message type', async () => {
|
||||
// @ts-expect-error intentional invalid type
|
||||
const res = await route({ type: 'nonsense' }, state, makePopupSender());
|
||||
expect(res).toEqual({ ok: false, error: 'unknown_message_type' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- Origin-bound autofill ---
|
||||
|
||||
describe('get_autofill_candidates uses sender.tab.url', () => {
|
||||
it('derives hostname from sender, not message', async () => {
|
||||
const state: RouterState = makeState();
|
||||
state.manifest = {
|
||||
schema_version: 2,
|
||||
items: {
|
||||
'aaaaaaaaaaaaaaaa': {
|
||||
id: 'aaaaaaaaaaaaaaaa', type: 'login', title: 'GitHub',
|
||||
tags: [], favorite: false, icon_hint: 'github.com',
|
||||
modified: 0, attachment_summaries: [],
|
||||
},
|
||||
'bbbbbbbbbbbbbbbb': {
|
||||
id: 'bbbbbbbbbbbbbbbb', type: 'login', title: 'Example',
|
||||
tags: [], favorite: false, icon_hint: 'example.com',
|
||||
modified: 0, attachment_summaries: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = await route(
|
||||
{ type: 'get_autofill_candidates' },
|
||||
state,
|
||||
makeContentSender('https://example.com/login'),
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
const data = res.data as { candidates: Array<[string, { title: string }]> };
|
||||
expect(data.candidates).toHaveLength(1);
|
||||
expect(data.candidates[0][1].title).toBe('Example');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- fill_credentials TOCTOU + origin verification ---
|
||||
|
||||
describe('fill_credentials captured-tab verification', () => {
|
||||
const FAKE_ITEM_ID = 'cccccccccccccccc';
|
||||
|
||||
function loginItem(url: string): Item {
|
||||
return {
|
||||
id: FAKE_ITEM_ID,
|
||||
title: 'Example',
|
||||
type: 'login',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: 0,
|
||||
modified: 0,
|
||||
core: { type: 'login', username: 'alice', password: 'hunter2', url },
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
};
|
||||
}
|
||||
|
||||
function primeUnlocked(state: RouterState): void {
|
||||
// Provide a fake handle + githost so the handler's "vault_locked" guard
|
||||
// passes — values don't matter because vault is mocked.
|
||||
vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
|
||||
state.gitHost = {} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(session.getCurrent).mockReset();
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockReset();
|
||||
(chrome.tabs.get as ReturnType<typeof vi.fn>).mockReset();
|
||||
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
it('returns tab_navigated when captured tab hostname differs from current', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
// chrome.tabs.get returns a tab that has navigated to a DIFFERENT host.
|
||||
(chrome.tabs.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 42,
|
||||
url: 'https://evil.example/landing',
|
||||
});
|
||||
|
||||
const res = await route(
|
||||
{
|
||||
type: 'fill_credentials',
|
||||
id: FAKE_ITEM_ID,
|
||||
capturedTabId: 42,
|
||||
capturedUrl: 'https://example.com/login',
|
||||
},
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(res).toEqual({ ok: false, error: 'tab_navigated' });
|
||||
// We must NOT have attempted to deliver credentials.
|
||||
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns origin_mismatch when item hostname differs from current tab', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
// Tab is still on example.com (matches capturedUrl) …
|
||||
(chrome.tabs.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 42,
|
||||
url: 'https://example.com/login',
|
||||
});
|
||||
// … but the item we'd fill belongs to github.com.
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
|
||||
loginItem('https://github.com/login'),
|
||||
);
|
||||
|
||||
const res = await route(
|
||||
{
|
||||
type: 'fill_credentials',
|
||||
id: FAKE_ITEM_ID,
|
||||
capturedTabId: 42,
|
||||
capturedUrl: 'https://example.com/login',
|
||||
},
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(res).toEqual({ ok: false, error: 'origin_mismatch' });
|
||||
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards fill_credentials with expectedHost when all checks pass', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
(chrome.tabs.get as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 42,
|
||||
url: 'https://example.com/login',
|
||||
});
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
|
||||
loginItem('https://example.com/login'),
|
||||
);
|
||||
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
|
||||
|
||||
const res = await route(
|
||||
{
|
||||
type: 'fill_credentials',
|
||||
id: FAKE_ITEM_ID,
|
||||
capturedTabId: 42,
|
||||
capturedUrl: 'https://example.com/login',
|
||||
},
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(res).toEqual({ ok: true });
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(42, {
|
||||
type: 'fill_credentials',
|
||||
username: 'alice',
|
||||
password: 'hunter2',
|
||||
expectedHost: 'example.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- setup-tab exception scope ---
|
||||
//
|
||||
// Setup is allowed a narrow subset of popup-only messages:
|
||||
// - save_setup (final wire-up)
|
||||
// - rate_passphrase (zxcvbn meter during passphrase entry)
|
||||
// - is_unlocked (step-4 extension detection)
|
||||
// Everything else popup-only must be rejected from setup.
|
||||
|
||||
describe('setup tab exception scope', () => {
|
||||
it('accepts rate_passphrase from the setup tab (zxcvbn meter)', async () => {
|
||||
const state = makeState();
|
||||
const res = await route(
|
||||
{ type: 'rate_passphrase', passphrase: 'correct horse battery staple parapet' },
|
||||
state,
|
||||
makeSetupSender(),
|
||||
);
|
||||
expect(res).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('accepts is_unlocked from the setup tab (step-4 detection)', async () => {
|
||||
const state = makeState();
|
||||
const res = await route({ type: 'is_unlocked' }, state, makeSetupSender());
|
||||
expect(res).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects fill_credentials from the setup tab (outside the allowlist)', async () => {
|
||||
const state = makeState();
|
||||
const res = await route(
|
||||
{
|
||||
type: 'fill_credentials',
|
||||
id: 'cccccccccccccccc',
|
||||
capturedTabId: 42,
|
||||
capturedUrl: 'https://example.com/',
|
||||
},
|
||||
state,
|
||||
makeSetupSender(),
|
||||
);
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
|
||||
it('rejects unlock from the setup tab (outside the allowlist)', async () => {
|
||||
const state = makeState();
|
||||
const res = await route(
|
||||
{ type: 'unlock', passphrase: 'hunter2' },
|
||||
state,
|
||||
makeSetupSender(),
|
||||
);
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- isContent rejects unknown sender.id ---
|
||||
|
||||
describe('isContent sender.id guard', () => {
|
||||
it('rejects content-shaped sender whose id is not the extension id', async () => {
|
||||
const state = makeState();
|
||||
const sender: chrome.runtime.MessageSender = {
|
||||
tab: { id: 42, url: 'https://example.com/' } as chrome.tabs.Tab,
|
||||
frameId: 0,
|
||||
id: 'some-other-extension', // NOT chrome.runtime.id
|
||||
};
|
||||
const res = await route({ type: 'get_autofill_candidates' }, state, sender);
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- capture_save_login (content-callable, origin-bound) ---
|
||||
|
||||
describe('capture_save_login', () => {
|
||||
const EXISTING_ID = 'dddddddddddddddd';
|
||||
|
||||
function loginItem(url: string, username: string, password: string): Item {
|
||||
return {
|
||||
id: EXISTING_ID,
|
||||
title: 'Example',
|
||||
type: 'login',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: 0,
|
||||
modified: 0,
|
||||
core: { type: 'login', username, password, url },
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
};
|
||||
}
|
||||
|
||||
function primeUnlocked(state: RouterState): void {
|
||||
vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never);
|
||||
state.gitHost = {} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(session.getCurrent).mockReset();
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockReset();
|
||||
vi.mocked(vault.encryptAndWriteItem).mockReset();
|
||||
vi.mocked(vault.encryptAndWriteManifest).mockReset();
|
||||
vi.mocked(vault.encryptAndWriteItem).mockResolvedValue(undefined);
|
||||
vi.mocked(vault.encryptAndWriteManifest).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('accepts capture_save_login from top-frame content', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
const res = await route(
|
||||
{ type: 'capture_save_login', username: 'alice', password: 'hunter2' },
|
||||
state,
|
||||
makeContentSender('https://example.com/login'),
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects capture_save_login from popup', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
const res = await route(
|
||||
{ type: 'capture_save_login', username: 'alice', password: 'hunter2' },
|
||||
state,
|
||||
makePopupSender(),
|
||||
);
|
||||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
|
||||
it('update path: existing (host, username) match rotates the password', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
// Seed manifest with a login for example.com.
|
||||
state.manifest = {
|
||||
schema_version: 2,
|
||||
items: {
|
||||
[EXISTING_ID]: {
|
||||
id: EXISTING_ID, type: 'login', title: 'Example',
|
||||
tags: [], favorite: false, icon_hint: 'example.com',
|
||||
modified: 0, attachment_summaries: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
|
||||
loginItem('https://example.com/', 'alice', 'oldpass'),
|
||||
);
|
||||
|
||||
const res = await route(
|
||||
{ type: 'capture_save_login', username: 'alice', password: 'newpass' },
|
||||
state,
|
||||
makeContentSender('https://example.com/login'),
|
||||
);
|
||||
expect(res).toMatchObject({ ok: true, data: { action: 'updated', id: EXISTING_ID } });
|
||||
// Verify write was invoked with a core whose password is the new one.
|
||||
expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1);
|
||||
const writtenItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3];
|
||||
expect(writtenItem.id).toBe(EXISTING_ID);
|
||||
if (writtenItem.core.type !== 'login') throw new Error('expected login core');
|
||||
expect(writtenItem.core.password).toBe('newpass');
|
||||
expect(writtenItem.core.username).toBe('alice');
|
||||
});
|
||||
|
||||
it('add path: no match creates a new item bound to senderHost', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
// Empty manifest — no candidates.
|
||||
state.manifest = { schema_version: 2, items: {} };
|
||||
|
||||
const res = await route(
|
||||
{ type: 'capture_save_login', username: 'bob', password: 's3cret' },
|
||||
state,
|
||||
makeContentSender('https://example.com/signup'),
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
const data = res.data as { action: string; id: string };
|
||||
expect(data.action).toBe('added');
|
||||
expect(data.id).toBe('fakeitemid0000ab'); // from stub new_item_id()
|
||||
}
|
||||
expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1);
|
||||
const newItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3];
|
||||
expect(newItem.title).toBe('example.com');
|
||||
if (newItem.core.type !== 'login') throw new Error('expected login core');
|
||||
expect(newItem.core.url).toBe('https://example.com');
|
||||
expect(newItem.core.username).toBe('bob');
|
||||
expect(newItem.core.password).toBe('s3cret');
|
||||
// Manifest entry should have been added too.
|
||||
expect(state.manifest!.items['fakeitemid0000ab']).toBeDefined();
|
||||
});
|
||||
|
||||
it('origin_mismatch when existing item for same username has a different host', async () => {
|
||||
const state = makeState();
|
||||
primeUnlocked(state);
|
||||
// Manifest says there's a match for example.com (icon_hint), but the
|
||||
// underlying item actually belongs to github.com — defense-in-depth
|
||||
// check should reject.
|
||||
state.manifest = {
|
||||
schema_version: 2,
|
||||
items: {
|
||||
[EXISTING_ID]: {
|
||||
id: EXISTING_ID, type: 'login', title: 'Example',
|
||||
tags: [], favorite: false, icon_hint: 'example.com',
|
||||
modified: 0, attachment_summaries: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
|
||||
loginItem('https://github.com/', 'alice', 'oldpass'),
|
||||
);
|
||||
|
||||
const res = await route(
|
||||
{ type: 'capture_save_login', username: 'alice', password: 'newpass' },
|
||||
state,
|
||||
makeContentSender('https://example.com/login'),
|
||||
);
|
||||
expect(res).toEqual({ ok: false, error: 'origin_mismatch' });
|
||||
expect(vault.encryptAndWriteItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
204
extension/src/service-worker/router/content-callable.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/// Content-script-callable message handlers.
|
||||
///
|
||||
/// Origin is always derived from sender.tab.url — never trust fields on msg.
|
||||
/// Router has already verified sender.frameId === 0 (top-frame only) and
|
||||
/// sender.tab !== undefined.
|
||||
|
||||
import type { ContentMessage, Response } from '../../shared/messages';
|
||||
import type { Item, Manifest } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
import * as session from '../session';
|
||||
|
||||
export interface ContentState {
|
||||
manifest: Manifest | null;
|
||||
gitHost: GitHost | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasm: any;
|
||||
}
|
||||
|
||||
export async function handle(
|
||||
msg: ContentMessage,
|
||||
state: ContentState,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<Response> {
|
||||
const senderHost = safeHostname(sender.tab?.url ?? '');
|
||||
if (!senderHost) return { ok: false, error: 'invalid_sender_url' };
|
||||
|
||||
switch (msg.type) {
|
||||
case 'get_autofill_candidates': {
|
||||
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
return {
|
||||
ok: true,
|
||||
data: { candidates: vault.findByHostname(state.manifest, senderHost) },
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_credentials': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
|
||||
const itemHost = safeHostname(item.core.url ?? '');
|
||||
if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' };
|
||||
|
||||
// TOFU origin-ack check (VaultSettings.autofill_origin_acks):
|
||||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||
const acks = settings.autofill_origin_acks ?? {};
|
||||
if (!(senderHost in acks)) {
|
||||
return { ok: true, data: { requires_ack: true, hostname: senderHost } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
username: item.core.username ?? '',
|
||||
password: item.core.password ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'check_credential': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Settings-gating: capture off or site blacklisted → skip.
|
||||
const captureSettings = await loadDeviceSettings();
|
||||
if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } };
|
||||
|
||||
const blacklist = await loadBlacklist();
|
||||
if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } };
|
||||
|
||||
const candidates = vault.findByHostname(state.manifest, senderHost);
|
||||
if (candidates.length === 0) return { ok: true, data: { action: 'save' } };
|
||||
|
||||
for (const [itemId, entry] of candidates) {
|
||||
if (entry.type !== 'login') continue;
|
||||
const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId);
|
||||
if (full.core.type !== 'login') continue;
|
||||
if (full.core.username === msg.username) {
|
||||
if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } };
|
||||
return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } };
|
||||
}
|
||||
}
|
||||
return { ok: true, data: { action: 'save' } };
|
||||
}
|
||||
|
||||
case 'blacklist_site': {
|
||||
const bl = await loadBlacklist();
|
||||
if (!bl.includes(senderHost)) {
|
||||
bl.push(senderHost);
|
||||
await saveBlacklist(bl);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'capture_save_login': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
// Look for an existing login for this origin + username. Origin is
|
||||
// always senderHost (derived from sender.tab.url by the router) — the
|
||||
// content script cannot influence which host we bind to.
|
||||
const candidates = vault.findByHostname(state.manifest, senderHost);
|
||||
for (const [id, entry] of candidates) {
|
||||
if (entry.type !== 'login') continue;
|
||||
const full = await vault.fetchAndDecryptItem(state.gitHost, handle, id);
|
||||
if (full.core.type !== 'login') continue;
|
||||
if (full.core.username === msg.username) {
|
||||
// Defense in depth: verify the existing item's own URL hostname
|
||||
// matches senderHost. If it doesn't (e.g. manifest icon_hint
|
||||
// drifted from core.url), refuse to mutate — updating here would
|
||||
// silently bind a password to the wrong origin.
|
||||
const existingHost = safeHostname(full.core.url ?? '');
|
||||
if (existingHost !== senderHost) return { ok: false, error: 'origin_mismatch' };
|
||||
|
||||
// Update only the password field + modified timestamp.
|
||||
const updated: Item = {
|
||||
...full,
|
||||
modified: Math.floor(Date.now() / 1000),
|
||||
core: { ...full.core, password: msg.password },
|
||||
};
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, id, updated, `capture: update ${existingHost}`);
|
||||
state.manifest.items[id] = itemToManifestEntry(updated);
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${existingHost}`);
|
||||
return { ok: true, data: { action: 'updated', id } };
|
||||
}
|
||||
}
|
||||
|
||||
// No match → create a new Login item bound to senderHost. Title
|
||||
// defaults to the hostname; url is the sender's full origin when we
|
||||
// have it, otherwise derived from senderHost.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const newId = state.wasm.new_item_id();
|
||||
const senderOrigin = (() => {
|
||||
try { return sender.tab?.url ? new URL(sender.tab.url).origin : `https://${senderHost}`; }
|
||||
catch { return `https://${senderHost}`; }
|
||||
})();
|
||||
const item: Item = {
|
||||
id: newId,
|
||||
title: senderHost,
|
||||
type: 'login',
|
||||
tags: [],
|
||||
favorite: false,
|
||||
created: now,
|
||||
modified: now,
|
||||
core: {
|
||||
type: 'login',
|
||||
username: msg.username,
|
||||
password: msg.password,
|
||||
url: senderOrigin,
|
||||
},
|
||||
sections: [],
|
||||
attachments: [],
|
||||
field_history: {},
|
||||
};
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, newId, item, `capture: add ${senderHost}`);
|
||||
state.manifest.items[newId] = itemToManifestEntry(item);
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${senderHost}`);
|
||||
return { ok: true, data: { action: 'added', id: newId } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation (duplicated from popup-only for self-containment) ---
|
||||
|
||||
function itemToManifestEntry(item: Item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
|
||||
?? { captureEnabled: false, captureStyle: 'bar' };
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
70
extension/src/service-worker/router/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/// Single chrome.runtime.onMessage entry. Classifies the sender and dispatches
|
||||
/// to popup-only or content-callable handlers. Unauthorized senders are
|
||||
/// rejected with { ok: false, error: 'unauthorized_sender' }.
|
||||
|
||||
import type { PopupMessage, Request, Response } from '../../shared/messages';
|
||||
import { POPUP_ONLY_TYPES, CONTENT_CALLABLE_TYPES } from '../../shared/messages';
|
||||
import type { Manifest } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import * as popupOnly from './popup-only';
|
||||
import * as contentCallable from './content-callable';
|
||||
|
||||
export interface RouterState {
|
||||
manifest: Manifest | null;
|
||||
gitHost: GitHost | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasm: any;
|
||||
}
|
||||
|
||||
/// Popup-only messages the setup tab is also allowed to send.
|
||||
/// - save_setup: wires vault config + image into chrome.storage.local at end of init.
|
||||
/// - rate_passphrase: drives the zxcvbn strength meter during passphrase entry.
|
||||
/// - is_unlocked: setup step-4 pings the extension to detect "save config to extension" availability.
|
||||
const SETUP_ALLOWED: ReadonlySet<PopupMessage['type']> = new Set<PopupMessage['type']>([
|
||||
'save_setup',
|
||||
'rate_passphrase',
|
||||
'is_unlocked',
|
||||
]);
|
||||
|
||||
export async function route(
|
||||
msg: Request,
|
||||
state: RouterState,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<Response> {
|
||||
const popupUrl = chrome.runtime.getURL('popup.html');
|
||||
const setupUrl = chrome.runtime.getURL('setup.html');
|
||||
const senderUrl = sender.url ?? '';
|
||||
|
||||
const isPopup = senderUrl === popupUrl;
|
||||
const isSetup = senderUrl.startsWith(setupUrl);
|
||||
const isContent = sender.tab !== undefined
|
||||
&& sender.frameId === 0
|
||||
&& sender.id === chrome.runtime.id;
|
||||
|
||||
if (POPUP_ONLY_TYPES.has(msg.type as never)) {
|
||||
if (!(isPopup || (isSetup && SETUP_ALLOWED.has(msg.type as PopupMessage['type'])))) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario router] rejected popup-only message from wrong sender', {
|
||||
type: msg.type, senderUrl, isPopup, isSetup, isContent,
|
||||
});
|
||||
return { ok: false, error: 'unauthorized_sender' };
|
||||
}
|
||||
return popupOnly.handle(msg as never, state, sender);
|
||||
}
|
||||
|
||||
if (CONTENT_CALLABLE_TYPES.has(msg.type as never)) {
|
||||
if (!isContent) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario router] rejected content-only message from wrong sender', {
|
||||
type: msg.type, senderUrl, isPopup, isSetup, isContent,
|
||||
frameId: sender.frameId, senderId: sender.id,
|
||||
});
|
||||
return { ok: false, error: 'unauthorized_sender' };
|
||||
}
|
||||
return contentCallable.handle(msg as never, state, sender);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario router] unknown message type', { type: (msg as { type: string }).type });
|
||||
return { ok: false, error: 'unknown_message_type' };
|
||||
}
|
||||
273
extension/src/service-worker/router/popup-only.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/// Popup-callable message handlers.
|
||||
///
|
||||
/// Every export here assumes the router has already verified sender identity
|
||||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||||
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
import * as session from '../session';
|
||||
|
||||
// --- Shared ambient state owned by the SW module ---
|
||||
//
|
||||
// The router keeps these on a single `state` object and injects it into the
|
||||
// handler so testing can mock them without reaching for globals.
|
||||
|
||||
export interface PopupState {
|
||||
manifest: Manifest | null;
|
||||
gitHost: GitHost | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wasm: any;
|
||||
}
|
||||
|
||||
export async function handle(
|
||||
msg: PopupMessage,
|
||||
state: PopupState,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<Response> {
|
||||
void sender; // unused in most branches; retained for symmetry with content-callable
|
||||
switch (msg.type) {
|
||||
case 'is_unlocked':
|
||||
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
|
||||
|
||||
case 'unlock': {
|
||||
const w = state.wasm;
|
||||
const config = await loadConfig();
|
||||
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
||||
const imageB64 = await loadImageBase64();
|
||||
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
||||
|
||||
const imageBytes = base64ToUint8Array(imageB64);
|
||||
if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
const meta = await vault.fetchVaultMeta(state.gitHost);
|
||||
|
||||
const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
|
||||
session.setCurrent(handle);
|
||||
(msg as { passphrase: string }).passphrase = '';
|
||||
|
||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'lock':
|
||||
session.clearCurrent();
|
||||
state.manifest = null;
|
||||
return { ok: true };
|
||||
|
||||
case 'list_items': {
|
||||
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
|
||||
}
|
||||
|
||||
case 'get_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
return { ok: true, data: { item } };
|
||||
}
|
||||
|
||||
case 'add_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
const id = state.wasm.new_item_id();
|
||||
const item: Item = { ...msg.item, id };
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
|
||||
state.manifest.items[id] = itemToManifestEntry(item);
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
|
||||
return { ok: true, data: { id } };
|
||||
}
|
||||
|
||||
case 'update_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
|
||||
state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'delete_item': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
const entry = state.manifest.items[msg.id];
|
||||
if (!entry) return { ok: false, error: 'item_not_found' };
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const updated: Item = { ...item, trashed_at: now, modified: now };
|
||||
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
|
||||
state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
|
||||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_totp': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
if (item.core.type !== 'login' || !item.core.totp) {
|
||||
return { ok: false, error: 'no_totp' };
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now));
|
||||
return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
|
||||
}
|
||||
|
||||
case 'sync': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_setup_state':
|
||||
return { ok: true, data: await loadSetupState() };
|
||||
|
||||
case 'save_setup': {
|
||||
await chrome.storage.local.set({
|
||||
vaultConfig: msg.config,
|
||||
imageBase64: msg.imageBase64,
|
||||
});
|
||||
state.gitHost = null;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'rate_passphrase':
|
||||
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
|
||||
|
||||
case 'generate_password': {
|
||||
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
||||
return { ok: true, data: { password } };
|
||||
}
|
||||
|
||||
case 'fill_credentials':
|
||||
return handleFillCredentials(msg, state);
|
||||
|
||||
case 'ack_autofill_origin': {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||
const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
|
||||
const updated = { ...settings, autofill_origin_acks: acks };
|
||||
await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_settings':
|
||||
return { ok: true, data: { settings: await loadDeviceSettings() } };
|
||||
|
||||
case 'update_settings': {
|
||||
const current = await loadDeviceSettings();
|
||||
await saveDeviceSettings({ ...current, ...msg.settings });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'get_blacklist':
|
||||
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
||||
|
||||
case 'remove_blacklist': {
|
||||
const bl = await loadBlacklist();
|
||||
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- fill_credentials with captured-tab verification (audit M5) ---
|
||||
|
||||
async function handleFillCredentials(
|
||||
msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
|
||||
state: PopupState,
|
||||
): Promise<Response> {
|
||||
const handle = session.getCurrent();
|
||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||
|
||||
let tab: chrome.tabs.Tab;
|
||||
try { tab = await chrome.tabs.get(msg.capturedTabId); }
|
||||
catch { return { ok: false, error: 'captured_tab_gone' }; }
|
||||
|
||||
const currentHost = safeHostname(tab.url ?? '');
|
||||
const capturedHost = safeHostname(msg.capturedUrl);
|
||||
if (!currentHost || !capturedHost || currentHost !== capturedHost) {
|
||||
return { ok: false, error: 'tab_navigated' };
|
||||
}
|
||||
|
||||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||||
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
|
||||
const itemHost = safeHostname(item.core.url ?? '');
|
||||
if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };
|
||||
|
||||
// Pass the hostname the SW validated. The content script re-verifies
|
||||
// against location.href before filling — if the tab navigated between
|
||||
// our chrome.tabs.get check above and the sendMessage delivery below,
|
||||
// fill.ts rejects with 'origin_changed'.
|
||||
await chrome.tabs.sendMessage(msg.capturedTabId, {
|
||||
type: 'fill_credentials',
|
||||
username: item.core.username ?? '',
|
||||
password: item.core.password ?? '',
|
||||
expectedHost: currentHost,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- chrome.storage.local helpers (module-scoped so all handlers share) ---
|
||||
|
||||
async function loadConfig(): Promise<VaultConfig | null> {
|
||||
const r = await chrome.storage.local.get('vaultConfig');
|
||||
return (r.vaultConfig as VaultConfig) ?? null;
|
||||
}
|
||||
|
||||
async function loadImageBase64(): Promise<string | null> {
|
||||
const r = await chrome.storage.local.get('imageBase64');
|
||||
return (r.imageBase64 as string) ?? null;
|
||||
}
|
||||
|
||||
async function loadSetupState(): Promise<SetupState> {
|
||||
const config = await loadConfig();
|
||||
const imageBase64 = await loadImageBase64();
|
||||
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
||||
}
|
||||
|
||||
async function loadDeviceSettings(): Promise<DeviceSettings> {
|
||||
const r = await chrome.storage.local.get('relicarioSettings');
|
||||
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||||
}
|
||||
|
||||
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ relicarioSettings: s });
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const r = await chrome.storage.local.get('captureBlacklist');
|
||||
return (r.captureBlacklist as string[]) ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
|
||||
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
|
||||
|
||||
function itemToManifestEntry(item: Item) {
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
tags: item.tags,
|
||||
favorite: item.favorite,
|
||||
group: item.group,
|
||||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||||
? safeHostname(item.core.url) : undefined,
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.map((a) => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string | undefined {
|
||||
try { return new URL(url).hostname; } catch { return undefined; }
|
||||
}
|
||||
28
extension/src/service-worker/session.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/// Single module-scope "current" SessionHandle.
|
||||
///
|
||||
/// α assumes one vault per extension install. The master key lives only
|
||||
/// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module
|
||||
/// just holds the opaque handle that names it.
|
||||
///
|
||||
/// Future multi-vault (β+) would replace `current` with
|
||||
/// `Map<vaultId, SessionHandle>` and thread `vaultId` through every
|
||||
/// handler. Deliberate α simplicity — not an oversight.
|
||||
|
||||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||
|
||||
let current: SessionHandle | null = null;
|
||||
|
||||
export function setCurrent(h: SessionHandle): void { current = h; }
|
||||
|
||||
export function getCurrent(): SessionHandle | null { return current; }
|
||||
|
||||
export function requireCurrent(): SessionHandle {
|
||||
if (!current) throw new Error('vault_locked');
|
||||
return current;
|
||||
}
|
||||
|
||||
export function clearCurrent(): void {
|
||||
if (!current) return;
|
||||
try { current.free(); } catch { /* already freed */ }
|
||||
current = null;
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
/// Vault operations module.
|
||||
///
|
||||
/// Bridges the WASM crypto functions with the git host API to provide
|
||||
/// high-level vault operations: fetch/decrypt manifest, fetch/decrypt entries,
|
||||
/// encrypt/write entries, search, and URL matching.
|
||||
/// Typed-item vault operations. All calls are handle-keyed — the master key
|
||||
/// never crosses the WASM boundary.
|
||||
|
||||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||||
import type { GitHost } from './git-host';
|
||||
import type { Entry, Manifest, ManifestEntry } from '../shared/types';
|
||||
import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
|
||||
|
||||
// WASM module reference — set once during init.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let wasm: any = null;
|
||||
|
||||
/// Store the WASM module reference after initialization.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function setWasm(w: any): void {
|
||||
wasm = w;
|
||||
}
|
||||
export function setWasm(w: any): void { wasm = w; }
|
||||
|
||||
function requireWasm(): any {
|
||||
if (!wasm) throw new Error('WASM module not initialized');
|
||||
return wasm;
|
||||
}
|
||||
|
||||
/// Vault metadata: salt and KDF params stored unencrypted in the repo.
|
||||
export interface VaultMeta {
|
||||
salt: Uint8Array;
|
||||
paramsJson: string;
|
||||
}
|
||||
|
||||
/// Read the vault salt and KDF params from the git repo.
|
||||
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
|
||||
const saltBytes = await git.readFile('.relicario/salt');
|
||||
const paramsRaw = await git.readFile('.relicario/params.json');
|
||||
@@ -36,102 +28,115 @@ export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
|
||||
return { salt: saltBytes, paramsJson };
|
||||
}
|
||||
|
||||
/// Fetch and decrypt the manifest from the git repo.
|
||||
// --- Manifest ---
|
||||
|
||||
export async function fetchAndDecryptManifest(
|
||||
git: GitHost,
|
||||
masterKey: Uint8Array,
|
||||
handle: SessionHandle,
|
||||
): Promise<Manifest> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = await git.readFile('manifest.enc');
|
||||
const json = w.decrypt_manifest(ciphertext, masterKey);
|
||||
return JSON.parse(json) as Manifest;
|
||||
return w.manifest_decrypt(handle, ciphertext) as Manifest;
|
||||
}
|
||||
|
||||
/// Fetch and decrypt a single entry from the git repo.
|
||||
export async function fetchAndDecryptEntry(
|
||||
git: GitHost,
|
||||
masterKey: Uint8Array,
|
||||
id: string,
|
||||
): Promise<Entry> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = await git.readFile(`entries/${id}.enc`);
|
||||
const json = w.decrypt_entry(ciphertext, masterKey);
|
||||
return JSON.parse(json) as Entry;
|
||||
}
|
||||
|
||||
/// Encrypt an entry and write it to the git repo.
|
||||
export async function encryptAndWriteEntry(
|
||||
git: GitHost,
|
||||
masterKey: Uint8Array,
|
||||
id: string,
|
||||
entry: Entry,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const w = requireWasm();
|
||||
const entryJson = JSON.stringify(entry);
|
||||
const ciphertext = w.encrypt_entry(entryJson, masterKey);
|
||||
await git.writeFile(`entries/${id}.enc`, ciphertext, message);
|
||||
}
|
||||
|
||||
/// Encrypt the manifest and write it to the git repo.
|
||||
export async function encryptAndWriteManifest(
|
||||
git: GitHost,
|
||||
masterKey: Uint8Array,
|
||||
handle: SessionHandle,
|
||||
manifest: Manifest,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const w = requireWasm();
|
||||
const manifestJson = JSON.stringify(manifest);
|
||||
const ciphertext = w.encrypt_manifest(manifestJson, masterKey);
|
||||
const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest));
|
||||
await git.writeFile('manifest.enc', ciphertext, message);
|
||||
}
|
||||
|
||||
/// Filter manifest entries by group (case-insensitive). If no group given, returns all.
|
||||
export function listEntries(
|
||||
// --- Items ---
|
||||
|
||||
export async function fetchAndDecryptItem(
|
||||
git: GitHost,
|
||||
handle: SessionHandle,
|
||||
id: ItemId,
|
||||
): Promise<Item> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = await git.readFile(`items/${id}.enc`);
|
||||
return w.item_decrypt(handle, ciphertext) as Item;
|
||||
}
|
||||
|
||||
export async function encryptAndWriteItem(
|
||||
git: GitHost,
|
||||
handle: SessionHandle,
|
||||
id: ItemId,
|
||||
item: Item,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
|
||||
await git.writeFile(`items/${id}.enc`, ciphertext, message);
|
||||
}
|
||||
|
||||
// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---
|
||||
|
||||
export async function fetchAndDecryptSettings(
|
||||
git: GitHost,
|
||||
handle: SessionHandle,
|
||||
): Promise<VaultSettings> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = await git.readFile('settings.enc');
|
||||
return w.settings_decrypt(handle, ciphertext) as VaultSettings;
|
||||
}
|
||||
|
||||
export async function encryptAndWriteSettings(
|
||||
git: GitHost,
|
||||
handle: SessionHandle,
|
||||
settings: VaultSettings,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const w = requireWasm();
|
||||
const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
|
||||
await git.writeFile('settings.enc', ciphertext, message);
|
||||
}
|
||||
|
||||
// --- In-memory manifest helpers ---
|
||||
|
||||
export function listItems(
|
||||
manifest: Manifest,
|
||||
group?: string,
|
||||
): Array<[string, ManifestEntry]> {
|
||||
const entries = Object.entries(manifest.entries);
|
||||
if (!group) return entries;
|
||||
): Array<[ItemId, ManifestEntry]> {
|
||||
const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>;
|
||||
// Hide trashed items from the default list view.
|
||||
const live = entries.filter(([, e]) => e.trashed_at === undefined);
|
||||
if (!group) return live;
|
||||
const g = group.toLowerCase();
|
||||
return entries.filter(([, e]) =>
|
||||
e.group?.toLowerCase() === g
|
||||
);
|
||||
return live.filter(([, e]) => e.group?.toLowerCase() === g);
|
||||
}
|
||||
|
||||
/// Case-insensitive substring search on name, url, and username.
|
||||
export function searchEntries(
|
||||
export function searchItems(
|
||||
manifest: Manifest,
|
||||
query: string,
|
||||
): Array<[string, ManifestEntry]> {
|
||||
): Array<[ItemId, ManifestEntry]> {
|
||||
const q = query.toLowerCase();
|
||||
return Object.entries(manifest.entries).filter(([, e]) => {
|
||||
if (e.name.toLowerCase().includes(q)) return true;
|
||||
if (e.url?.toLowerCase().includes(q)) return true;
|
||||
if (e.username?.toLowerCase().includes(q)) return true;
|
||||
return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
|
||||
.filter(([, e]) => e.trashed_at === undefined)
|
||||
.filter(([, e]) => {
|
||||
if (e.title.toLowerCase().includes(q)) return true;
|
||||
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Find entries whose URL matches the given page URL by hostname.
|
||||
export function findByUrl(
|
||||
/// Match manifest entries against a page hostname.
|
||||
///
|
||||
/// icon_hint is derived by the Rust core (crates/relicario-core/src/manifest.rs)
|
||||
/// from LoginCore.url's hostname, so equality on icon_hint is the cheapest match.
|
||||
/// α is intentionally coarse: no www.-stripping, no public-suffix matching
|
||||
/// (`www.github.com` saved items will not match `github.com`, and vice versa).
|
||||
/// Tighter matching is a 1C-β/γ concern.
|
||||
export function findByHostname(
|
||||
manifest: Manifest,
|
||||
url: string,
|
||||
): Array<[string, ManifestEntry]> {
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(manifest.entries).filter(([, e]) => {
|
||||
if (!e.url) return false;
|
||||
try {
|
||||
const entryHost = new URL(e.url).hostname;
|
||||
return entryHost === hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
hostname: string,
|
||||
): Array<[ItemId, ManifestEntry]> {
|
||||
const h = hostname.toLowerCase();
|
||||
return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
|
||||
.filter(([, e]) => e.trashed_at === undefined)
|
||||
.filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
/// Step 4: Finish (download reference image, push config to extension or copy JSON)
|
||||
|
||||
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
|
||||
import type { GitHost } from '../service-worker/git-host';
|
||||
import type { VaultConfig } from '../shared/types';
|
||||
|
||||
// --- WASM module (loaded dynamically) ---
|
||||
@@ -37,6 +36,11 @@ interface WizardState {
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
|
||||
passphraseScore: number;
|
||||
passphraseGuessesLog10: number; // -1 before first rating
|
||||
passphraseVisible: boolean;
|
||||
confirmVisible: boolean;
|
||||
referenceImageBytes: Uint8Array | null;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
@@ -54,6 +58,10 @@ const state: WizardState = {
|
||||
carrierImageBytes: null,
|
||||
passphrase: '',
|
||||
passphraseConfirm: '',
|
||||
passphraseScore: -1,
|
||||
passphraseGuessesLog10: -1,
|
||||
passphraseVisible: false,
|
||||
confirmVisible: false,
|
||||
referenceImageBytes: null,
|
||||
creating: false,
|
||||
error: null,
|
||||
@@ -71,17 +79,138 @@ function escapeHtml(s: string): string {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' {
|
||||
let score = 0;
|
||||
if (pw.length >= 8) score++;
|
||||
if (pw.length >= 14) score++;
|
||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
|
||||
if (/[0-9]/.test(pw)) score++;
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) score++;
|
||||
if (score <= 1) return 'weak';
|
||||
if (score <= 2) return 'fair';
|
||||
if (score <= 3) return 'good';
|
||||
return 'strong';
|
||||
interface Strength { score: number; guessesLog10: number }
|
||||
|
||||
/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4]
|
||||
/// and guesses_log10, or -1 on both if the round-trip failed.
|
||||
function ratePassphrase(passphrase: string): Promise<Strength> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: 'rate_passphrase', passphrase },
|
||||
(response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError);
|
||||
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||
}
|
||||
if (!response?.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase rejected by SW:', response);
|
||||
resolve({ score: -1, guessesLog10: -1 }); return;
|
||||
}
|
||||
resolve({
|
||||
score: response.data?.score ?? -1,
|
||||
guessesLog10: response.data?.guesses_log10 ?? -1,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[relicario setup] rate_passphrase threw:', err);
|
||||
resolve({ score: -1, guessesLog10: -1 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 150ms debounce around the rate_passphrase call so we don't hammer the SW
|
||||
/// on every keystroke. The last invocation wins.
|
||||
let rateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void {
|
||||
if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer);
|
||||
rateDebounceTimer = setTimeout(async () => {
|
||||
rateDebounceTimer = null;
|
||||
if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; }
|
||||
onResult(await ratePassphrase(passphrase));
|
||||
}, 150);
|
||||
}
|
||||
|
||||
const STRENGTH_LABELS: Record<number, { text: string; cls: string }> = {
|
||||
0: { text: 'very weak', cls: 's-very-weak' },
|
||||
1: { text: 'weak', cls: 's-weak' },
|
||||
2: { text: 'fair', cls: 's-fair' },
|
||||
3: { text: 'good', cls: 's-good' },
|
||||
4: { text: 'strong', cls: 's-strong' },
|
||||
};
|
||||
|
||||
/// Render the entropy readout as "~10^N guesses to crack" or a friendlier
|
||||
/// shorthand for large values. Returns empty string when no data.
|
||||
function entropyText(guessesLog10: number): string {
|
||||
if (guessesLog10 < 0) return '';
|
||||
const rounded = Math.round(guessesLog10);
|
||||
if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`;
|
||||
if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`;
|
||||
if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`;
|
||||
if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`;
|
||||
if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`;
|
||||
return `~10^${rounded} guesses — effectively uncrackable`;
|
||||
}
|
||||
|
||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
||||
/// focus and the user's cursor position is preserved). Also updates the
|
||||
/// char counter and confirm-match indicator live.
|
||||
function updateStrengthUi(): void {
|
||||
const bar = document.getElementById('strength-bar');
|
||||
const label = document.getElementById('strength-label');
|
||||
const entropy = document.getElementById('entropy-line');
|
||||
const counter = document.getElementById('passphrase-counter');
|
||||
const matchInd = document.getElementById('match-indicator');
|
||||
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
||||
|
||||
const score = state.passphraseScore;
|
||||
const guessesLog10 = state.passphraseGuessesLog10;
|
||||
|
||||
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
||||
|
||||
if (label) {
|
||||
if (score < 0) {
|
||||
label.className = 'strength-label';
|
||||
label.innerHTML = ' ';
|
||||
} else {
|
||||
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
|
||||
label.className = `strength-label ${meta.cls}`;
|
||||
label.textContent = meta.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (entropy) {
|
||||
const txt = entropyText(guessesLog10);
|
||||
entropy.textContent = txt;
|
||||
entropy.style.visibility = txt ? 'visible' : 'hidden';
|
||||
}
|
||||
|
||||
if (counter) {
|
||||
const n = state.passphrase.length;
|
||||
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (matchInd) {
|
||||
const p = state.passphrase;
|
||||
const c = state.passphraseConfirm;
|
||||
if (!p || !c) {
|
||||
matchInd.className = 'match-indicator';
|
||||
matchInd.textContent = '';
|
||||
} else if (p === c) {
|
||||
matchInd.className = 'match-indicator ok';
|
||||
matchInd.textContent = '✓';
|
||||
} else {
|
||||
matchInd.className = 'match-indicator bad';
|
||||
matchInd.textContent = '✗';
|
||||
}
|
||||
}
|
||||
|
||||
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
|
||||
if (create) {
|
||||
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
|
||||
create.disabled = disabled;
|
||||
create.title = disabled
|
||||
? (score < 3
|
||||
? 'passphrase must score "good" or better'
|
||||
: !state.passphraseConfirm ? 'confirm your passphrase'
|
||||
: !matchOk ? 'passphrases do not match'
|
||||
: '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
@@ -266,11 +395,35 @@ function attachStep2(): void {
|
||||
// --- Step 3: Create Vault ---
|
||||
|
||||
function renderStep3(): string {
|
||||
const strength = state.passphrase ? passphraseStrength(state.passphrase) : null;
|
||||
const score = state.passphraseScore;
|
||||
const guessesLog10 = state.passphraseGuessesLog10;
|
||||
const hasScore = score >= 0;
|
||||
const meterClass = hasScore ? `s${score}` : '';
|
||||
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
|
||||
const labelClass = labelMeta?.cls ?? '';
|
||||
const labelText = labelMeta?.text ?? ' ';
|
||||
const entropy = entropyText(guessesLog10);
|
||||
|
||||
const p = state.passphrase;
|
||||
const c = state.passphraseConfirm;
|
||||
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
|
||||
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
|
||||
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const cType = state.confirmVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const cToggle = state.confirmVisible ? 'hide' : 'show';
|
||||
|
||||
const matchOk = !c || p === c;
|
||||
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
|
||||
|
||||
const nChars = p.length;
|
||||
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<h3>create vault</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">carrier image (JPEG)</label>
|
||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
||||
@@ -281,23 +434,44 @@ function renderStep3(): string {
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
||||
</div>
|
||||
|
||||
<div class="pass-help">
|
||||
A long phrase of unrelated words is stronger than a short complex password.
|
||||
Your vault needs <strong>good</strong> (score ≥ 3) to continue.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase">
|
||||
${strength ? `
|
||||
<div class="strength-bar">
|
||||
<div class="strength-bar-fill ${strength}"></div>
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
|
||||
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
|
||||
` : ''}
|
||||
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
||||
<div class="seg i0"></div>
|
||||
<div class="seg i1"></div>
|
||||
<div class="seg i2"></div>
|
||||
<div class="seg i3"></div>
|
||||
<div class="seg i4"></div>
|
||||
</div>
|
||||
<div class="strength-row">
|
||||
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
||||
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
|
||||
</div>
|
||||
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
||||
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase">
|
||||
<div class="passphrase-field">
|
||||
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
||||
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
|
||||
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="create-btn" ${state.creating ? 'disabled' : ''}>
|
||||
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
|
||||
${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -323,26 +497,43 @@ function attachStep3(): void {
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
// Track passphrase changes without full re-render
|
||||
document.getElementById('passphrase')?.addEventListener('input', (e) => {
|
||||
// Track passphrase changes inline (no full re-render) so the input keeps focus.
|
||||
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
// Update strength bar inline
|
||||
const strength = passphraseStrength(state.passphrase);
|
||||
const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null;
|
||||
const label = document.querySelector('.strength-bar + .muted') as HTMLElement | null;
|
||||
if (bar) {
|
||||
bar.className = `strength-bar-fill ${strength}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = `strength: ${strength}`;
|
||||
}
|
||||
if (!bar && state.passphrase) {
|
||||
render();
|
||||
}
|
||||
// Update char counter + match indicator + button gate immediately on every keystroke.
|
||||
updateStrengthUi();
|
||||
// Score updates on the 150ms debounce to avoid SW hammering.
|
||||
scheduleRate(state.passphrase, (s) => {
|
||||
state.passphraseScore = s.score;
|
||||
state.passphraseGuessesLog10 = s.guessesLog10;
|
||||
updateStrengthUi();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
|
||||
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
|
||||
confirmInput?.addEventListener('input', (e) => {
|
||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||
updateStrengthUi();
|
||||
});
|
||||
|
||||
// Eye toggles — flip the input type and label without a full re-render so
|
||||
// focus + cursor position survive the click.
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('eye-btn');
|
||||
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
|
||||
passInput?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
|
||||
state.confirmVisible = !state.confirmVisible;
|
||||
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
|
||||
const btn = document.getElementById('confirm-eye-btn');
|
||||
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
|
||||
confirmInput?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
@@ -366,6 +557,17 @@ function attachStep3(): void {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
// Re-rate synchronously in case the button was clicked before the
|
||||
// debounced rater fired. Defence in depth — the button is already
|
||||
// disabled in the UI when score < 3 (audit H3).
|
||||
const strength = await ratePassphrase(state.passphrase);
|
||||
state.passphraseScore = strength.score;
|
||||
state.passphraseGuessesLog10 = strength.guessesLog10;
|
||||
if (state.passphraseScore < 3) {
|
||||
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (state.passphrase !== state.passphraseConfirm) {
|
||||
state.error = 'Passphrases do not match';
|
||||
render();
|
||||
@@ -376,80 +578,85 @@ function attachStep3(): void {
|
||||
state.error = null;
|
||||
render();
|
||||
|
||||
// Structured logging so silent failures become visible in DevTools.
|
||||
// eslint-disable-next-line no-console
|
||||
const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? '');
|
||||
|
||||
let stage = 'init';
|
||||
try {
|
||||
stage = 'load wasm';
|
||||
log(stage);
|
||||
const w = await loadWasm();
|
||||
|
||||
// 1. Generate 32-byte image secret
|
||||
stage = 'generate image secret';
|
||||
log(stage);
|
||||
const imageSecret = new Uint8Array(32);
|
||||
crypto.getRandomValues(imageSecret);
|
||||
|
||||
// 2. Embed secret into carrier JPEG
|
||||
stage = 'embed image secret';
|
||||
log(stage, { carrierBytes: state.carrierImageBytes.byteLength });
|
||||
state.referenceImageBytes = new Uint8Array(
|
||||
w.embed_image_secret(state.carrierImageBytes, imageSecret)
|
||||
w.embed_image_secret(state.carrierImageBytes, imageSecret),
|
||||
);
|
||||
log('embedded', { referenceBytes: state.referenceImageBytes.byteLength });
|
||||
|
||||
// 3. Generate 32-byte salt
|
||||
stage = 'generate salt';
|
||||
const salt = new Uint8Array(32);
|
||||
crypto.getRandomValues(salt);
|
||||
|
||||
// 4. Create KDF params
|
||||
const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
|
||||
|
||||
// 5. Derive master key
|
||||
const masterKey = w.derive_master_key(
|
||||
state.passphrase,
|
||||
imageSecret,
|
||||
salt,
|
||||
paramsJson,
|
||||
);
|
||||
stage = 'derive session handle';
|
||||
log(stage);
|
||||
// unlock() takes JPEG bytes with embedded secret (it extracts internally),
|
||||
// not the raw 32-byte secret.
|
||||
const handle = w.unlock(state.passphrase, state.referenceImageBytes, salt, paramsJson);
|
||||
log('handle acquired');
|
||||
|
||||
// 6. Encrypt empty manifest
|
||||
const manifestJson = '{"entries":{},"version":1}';
|
||||
const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey);
|
||||
stage = 'encrypt empty manifest';
|
||||
log(stage);
|
||||
const manifestJson = '{"schema_version":2,"items":{}}';
|
||||
const encryptedManifest = w.manifest_encrypt(handle, manifestJson);
|
||||
log('manifest encrypted', { bytes: encryptedManifest.length });
|
||||
|
||||
// 7. Push vault files via git API
|
||||
stage = 'push vault files';
|
||||
log(stage);
|
||||
const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
|
||||
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
|
||||
|
||||
await host.writeFile(
|
||||
'.relicario/salt',
|
||||
salt,
|
||||
'init: vault salt',
|
||||
);
|
||||
log('write .relicario/salt');
|
||||
await host.writeFile('.relicario/salt', salt, 'init: vault salt');
|
||||
|
||||
log('write .relicario/params.json');
|
||||
const paramsBytes = new TextEncoder().encode(paramsJson);
|
||||
await host.writeFile(
|
||||
'.relicario/params.json',
|
||||
paramsBytes,
|
||||
'init: KDF parameters',
|
||||
);
|
||||
await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters');
|
||||
|
||||
log('write .relicario/devices.json');
|
||||
const devicesJson = '{"devices":[]}';
|
||||
const devicesBytes = new TextEncoder().encode(devicesJson);
|
||||
await host.writeFile(
|
||||
'.relicario/devices.json',
|
||||
devicesBytes,
|
||||
'init: device registry',
|
||||
);
|
||||
await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry');
|
||||
|
||||
log('write manifest.enc');
|
||||
await host.writeFile(
|
||||
'manifest.enc',
|
||||
new Uint8Array(encryptedManifest),
|
||||
'init: encrypted manifest',
|
||||
);
|
||||
|
||||
// 8. Advance to step 4
|
||||
stage = 'release handle';
|
||||
w.lock(handle);
|
||||
|
||||
log('vault created — advancing to step 4');
|
||||
state.creating = false;
|
||||
state.step = 4;
|
||||
state.error = null;
|
||||
|
||||
// Detect extension
|
||||
detectExtension();
|
||||
|
||||
render();
|
||||
} catch (err: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err);
|
||||
state.creating = false;
|
||||
state.error = `Vault creation failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
state.error = `Vault creation failed at "${stage}": ${detail}`;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
33
extension/src/shared/__tests__/base32.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// extension/src/shared/__tests__/base32.test.ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { base32Decode, base32Encode } from '../base32';
|
||||
|
||||
describe('base32', () => {
|
||||
// RFC 4648 § 10 test vectors
|
||||
it('encodes empty', () => expect(base32Encode(new Uint8Array())).toBe(''));
|
||||
it('encodes "f"', () => expect(base32Encode(new TextEncoder().encode('f'))).toBe('MY'));
|
||||
it('encodes "fo"', () => expect(base32Encode(new TextEncoder().encode('fo'))).toBe('MZXQ'));
|
||||
it('encodes "foo"', () => expect(base32Encode(new TextEncoder().encode('foo'))).toBe('MZXW6'));
|
||||
it('encodes "foob"', () => expect(base32Encode(new TextEncoder().encode('foob'))).toBe('MZXW6YQ'));
|
||||
it('encodes "fooba"', () => expect(base32Encode(new TextEncoder().encode('fooba'))).toBe('MZXW6YTB'));
|
||||
it('encodes "foobar"',() => expect(base32Encode(new TextEncoder().encode('foobar'))).toBe('MZXW6YTBOI'));
|
||||
|
||||
it('decodes round-trip', () => {
|
||||
const bytes = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a]);
|
||||
expect(base32Decode(base32Encode(bytes))).toEqual(bytes);
|
||||
});
|
||||
|
||||
it('decodes case-insensitively', () => {
|
||||
expect(base32Decode('mzxw6')).toEqual(new TextEncoder().encode('foo'));
|
||||
});
|
||||
|
||||
it('decodes ignoring whitespace and padding', () => {
|
||||
expect(base32Decode('JBSW Y3DP EHPK 3PXP==')).toEqual(
|
||||
base32Decode('JBSWY3DPEHPK3PXP'),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid characters', () => {
|
||||
expect(() => base32Decode('MZ!W6')).toThrow();
|
||||
});
|
||||
});
|
||||
44
extension/src/shared/base32.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/// Minimal RFC 4648 base32 encode/decode for TOTP secret parsing.
|
||||
///
|
||||
/// Mirrors the encoder in crates/relicario-core/src/item.rs:base32_encode.
|
||||
/// Decode is case-insensitive, tolerates whitespace and `=` padding.
|
||||
|
||||
const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export function base32Encode(bytes: Uint8Array): string {
|
||||
let out = '';
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const b of bytes) {
|
||||
buffer = (buffer << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
const idx = (buffer >> (bits - 5)) & 0x1f;
|
||||
out += ALPHA[idx];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
const idx = (buffer << (5 - bits)) & 0x1f;
|
||||
out += ALPHA[idx];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(input: string): Uint8Array {
|
||||
const cleaned = input.replace(/\s+/g, '').replace(/=+$/g, '').toUpperCase();
|
||||
const out: number[] = [];
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const ch of cleaned) {
|
||||
const idx = ALPHA.indexOf(ch);
|
||||
if (idx === -1) throw new Error(`base32: invalid character "${ch}"`);
|
||||
buffer = (buffer << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((buffer >> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
@@ -1,68 +1,79 @@
|
||||
import type { Entry, Manifest, ManifestEntry, VaultConfig, SetupState } from './types';
|
||||
import type {
|
||||
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
||||
DeviceSettings, GeneratorRequest,
|
||||
} from './types';
|
||||
|
||||
// --- Request types (popup/content -> service worker) ---
|
||||
// --- Messages a popup (or setup page) may send ---
|
||||
|
||||
export type Request =
|
||||
export type PopupMessage =
|
||||
| { type: 'is_unlocked' }
|
||||
| { type: 'unlock'; passphrase: string }
|
||||
| { type: 'lock' }
|
||||
| { type: 'is_unlocked' }
|
||||
| { type: 'list_entries'; group?: string }
|
||||
| { type: 'get_entry'; id: string }
|
||||
| { type: 'search_entries'; query: string }
|
||||
| { type: 'add_entry'; entry: Entry }
|
||||
| { type: 'update_entry'; id: string; entry: Entry }
|
||||
| { type: 'delete_entry'; id: string }
|
||||
| { type: 'get_totp'; id: string }
|
||||
| { type: 'get_autofill_candidates'; url: string }
|
||||
| { type: 'get_credentials'; id: string }
|
||||
| { type: 'list_items'; group?: string }
|
||||
| { type: 'get_item'; id: ItemId }
|
||||
| { type: 'add_item'; item: Item }
|
||||
| { type: 'update_item'; id: ItemId; item: Item }
|
||||
| { type: 'delete_item'; id: ItemId } // soft-delete
|
||||
| { type: 'get_totp'; id: ItemId }
|
||||
| { type: 'sync' }
|
||||
| { type: 'get_setup_state' }
|
||||
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
||||
| { type: 'generate_password'; length: number }
|
||||
| { type: 'fill_credentials'; username: string; password: string }
|
||||
| { type: 'check_credential'; url: string; username: string; password: string }
|
||||
| { type: 'blacklist_site'; hostname: string }
|
||||
| { type: 'rate_passphrase'; passphrase: string }
|
||||
| { type: 'generate_password'; request: GeneratorRequest }
|
||||
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
|
||||
| { type: 'ack_autofill_origin'; hostname: string }
|
||||
| { type: 'get_settings' }
|
||||
| { type: 'update_settings'; settings: Partial<import('./types').RelicarioSettings> }
|
||||
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string };
|
||||
|
||||
// --- Response types (service worker -> popup/content) ---
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
// Note deliberate absence of a `url` field — the SW derives origin from sender.tab.url.
|
||||
|
||||
export type ContentMessage =
|
||||
| { type: 'get_autofill_candidates' }
|
||||
| { type: 'get_credentials'; id: ItemId }
|
||||
| { type: 'check_credential'; username: string; password: string }
|
||||
| { type: 'blacklist_site' }
|
||||
| { type: 'capture_save_login'; username: string; password: string };
|
||||
|
||||
// --- Union for chrome.runtime.sendMessage call sites ---
|
||||
|
||||
export type Request = PopupMessage | ContentMessage;
|
||||
|
||||
// --- Response ---
|
||||
|
||||
export type Response =
|
||||
| { ok: true; data?: unknown }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface UnlockResponse extends Extract<Response, { ok: true }> {
|
||||
data: undefined;
|
||||
}
|
||||
// --- Typed response helpers ---
|
||||
|
||||
export interface IsUnlockedResponse extends Extract<Response, { ok: true }> {
|
||||
data: { unlocked: boolean };
|
||||
}
|
||||
|
||||
export interface ListEntriesResponse extends Extract<Response, { ok: true }> {
|
||||
data: { entries: Array<[string, ManifestEntry]> };
|
||||
export interface ListItemsResponse extends Extract<Response, { ok: true }> {
|
||||
data: { items: Array<[ItemId, ManifestEntry]> };
|
||||
}
|
||||
|
||||
export interface GetEntryResponse extends Extract<Response, { ok: true }> {
|
||||
data: { entry: Entry };
|
||||
}
|
||||
|
||||
export interface SearchEntriesResponse extends Extract<Response, { ok: true }> {
|
||||
data: { entries: Array<[string, ManifestEntry]> };
|
||||
export interface GetItemResponse extends Extract<Response, { ok: true }> {
|
||||
data: { item: Item };
|
||||
}
|
||||
|
||||
export interface TotpResponse extends Extract<Response, { ok: true }> {
|
||||
data: { code: string; remaining_seconds: number };
|
||||
data: { code: string; expires_at: number };
|
||||
}
|
||||
|
||||
export interface AutofillCandidatesResponse extends Extract<Response, { ok: true }> {
|
||||
data: { candidates: Array<[string, ManifestEntry]> };
|
||||
data: { candidates: Array<[ItemId, ManifestEntry]> };
|
||||
}
|
||||
|
||||
export interface CredentialsResponse extends Extract<Response, { ok: true }> {
|
||||
data: { username: string; password: string };
|
||||
data:
|
||||
| { requires_ack: true; hostname: string }
|
||||
| { username: string; password: string };
|
||||
}
|
||||
|
||||
export interface SetupStateResponse extends Extract<Response, { ok: true }> {
|
||||
@@ -72,3 +83,22 @@ export interface SetupStateResponse extends Extract<Response, { ok: true }> {
|
||||
export interface GeneratePasswordResponse extends Extract<Response, { ok: true }> {
|
||||
data: { password: string };
|
||||
}
|
||||
|
||||
export interface RatePassphraseResponse extends Extract<Response, { ok: true }> {
|
||||
data: { score: number; guesses_log10: number };
|
||||
}
|
||||
|
||||
// --- Capability sets (consumed by the router) ---
|
||||
|
||||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
||||
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
||||
'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
|
||||
'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
|
||||
'remove_blacklist',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
||||
'capture_save_login',
|
||||
] as ContentMessage['type'][]);
|
||||
|
||||
@@ -1,32 +1,202 @@
|
||||
/// Full credential entry (matches Rust Entry struct in relicario-core).
|
||||
export interface Entry {
|
||||
name: string;
|
||||
url?: string;
|
||||
/// Typed-item shared TypeScript types.
|
||||
///
|
||||
/// These mirror the Rust core's serde serialization. See
|
||||
/// crates/relicario-core/src/item.rs, item_types/, and settings.rs
|
||||
/// for the source shapes.
|
||||
|
||||
// --- IDs ---
|
||||
|
||||
export type ItemId = string; // 16-char hex
|
||||
export type FieldId = string; // 16-char hex
|
||||
export type AttachmentId = string; // 16-char hex (sha256 of plaintext, truncated)
|
||||
|
||||
// --- ItemType / ItemCore ---
|
||||
|
||||
// snake_case from serde rename_all
|
||||
export type ItemType =
|
||||
| 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';
|
||||
|
||||
// ItemCore is internally-tagged on "type":
|
||||
// Login → { type: 'login', username, password, url, totp }
|
||||
export type ItemCore =
|
||||
| ({ type: 'login' } & LoginCore)
|
||||
| ({ type: 'secure_note' } & SecureNoteCore)
|
||||
| ({ type: 'identity' } & IdentityCore)
|
||||
| ({ type: 'card' } & CardCore)
|
||||
| ({ type: 'key' } & KeyCore)
|
||||
| ({ type: 'document' } & DocumentCore)
|
||||
| ({ type: 'totp' } & TotpCore);
|
||||
|
||||
// Optional fields use `?` because Rust #[serde(skip_serializing_if = "Option::is_none")]
|
||||
// omits them from the JSON; serde_wasm_bindgen produces `undefined` on read.
|
||||
|
||||
export interface LoginCore {
|
||||
username?: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
url?: string;
|
||||
totp?: TotpConfig;
|
||||
}
|
||||
|
||||
export interface SecureNoteCore { body: string; }
|
||||
|
||||
export interface IdentityCore {
|
||||
full_name?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
date_of_birth?: string; // "YYYY-MM-DD"
|
||||
}
|
||||
|
||||
export interface CardCore {
|
||||
number?: string;
|
||||
holder?: string;
|
||||
expiry?: { month: number; year: number };
|
||||
cvv?: string;
|
||||
pin?: string;
|
||||
kind: CardKind;
|
||||
}
|
||||
|
||||
export type CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other';
|
||||
|
||||
export interface KeyCore {
|
||||
key_material: string;
|
||||
label?: string;
|
||||
public_key?: string;
|
||||
algorithm?: string;
|
||||
}
|
||||
|
||||
export interface DocumentCore {
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
primary_attachment: AttachmentId;
|
||||
}
|
||||
|
||||
export interface TotpCore {
|
||||
config: TotpConfig;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// --- TOTP ---
|
||||
|
||||
export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };
|
||||
|
||||
export interface TotpConfig {
|
||||
secret: number[]; // Vec<u8> → JSON number array
|
||||
algorithm: 'sha1' | 'sha256' | 'sha512';
|
||||
digits: number;
|
||||
period_seconds: number;
|
||||
kind: TotpKind;
|
||||
}
|
||||
|
||||
// --- Sections + custom fields ---
|
||||
|
||||
export interface Section {
|
||||
name?: string;
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
id: FieldId;
|
||||
label: string;
|
||||
kind: FieldKind;
|
||||
value: FieldValue;
|
||||
hidden_by_default: boolean;
|
||||
}
|
||||
|
||||
export type FieldKind =
|
||||
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
|
||||
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
|
||||
|
||||
// adjacently-tagged { tag: "kind", content: "value" }
|
||||
export type FieldValue =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'multiline'; value: string }
|
||||
| { kind: 'password'; value: string }
|
||||
| { kind: 'concealed'; value: string }
|
||||
| { kind: 'url'; value: string }
|
||||
| { kind: 'email'; value: string }
|
||||
| { kind: 'phone'; value: string }
|
||||
| { kind: 'date'; value: string }
|
||||
| { kind: 'month_year'; value: { month: number; year: number } }
|
||||
| { kind: 'totp'; value: TotpConfig }
|
||||
| { kind: 'reference'; value: AttachmentId };
|
||||
|
||||
// --- Attachments + history ---
|
||||
|
||||
export interface AttachmentRef {
|
||||
id: AttachmentId;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
created: number;
|
||||
}
|
||||
|
||||
export interface FieldHistoryEntry {
|
||||
value: string;
|
||||
replaced_at: number;
|
||||
}
|
||||
|
||||
export interface AttachmentSummary {
|
||||
id: AttachmentId;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// --- Item envelope ---
|
||||
|
||||
export interface Item {
|
||||
id: ItemId;
|
||||
title: string;
|
||||
type: ItemType; // Rust r#type → JSON key "type"
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group?: string;
|
||||
notes?: string;
|
||||
totp_secret?: string;
|
||||
group?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created: number;
|
||||
modified: number;
|
||||
trashed_at?: number;
|
||||
core: ItemCore;
|
||||
sections: Section[];
|
||||
attachments: AttachmentRef[];
|
||||
field_history: Record<FieldId, FieldHistoryEntry[]>;
|
||||
}
|
||||
|
||||
/// Lightweight manifest entry for listing/searching without full decrypt.
|
||||
export interface ManifestEntry {
|
||||
name: string;
|
||||
url?: string;
|
||||
username?: string;
|
||||
group?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
// --- Manifest (schema_version 2) ---
|
||||
|
||||
/// Encrypted manifest containing all entry metadata.
|
||||
export interface Manifest {
|
||||
entries: Record<string, ManifestEntry>;
|
||||
version: number;
|
||||
schema_version: number; // 2
|
||||
items: Record<ItemId, ManifestEntry>;
|
||||
}
|
||||
|
||||
/// Configuration for connecting to a git host.
|
||||
export interface ManifestEntry {
|
||||
id: ItemId;
|
||||
type: ItemType;
|
||||
title: string;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group?: string;
|
||||
icon_hint?: string;
|
||||
modified: number;
|
||||
trashed_at?: number;
|
||||
attachment_summaries: AttachmentSummary[];
|
||||
}
|
||||
|
||||
// --- Vault settings (only the fields α touches) ---
|
||||
// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md
|
||||
// We leave retention/generator/caps opaque to α so we don't accidentally mutate them.
|
||||
|
||||
export interface VaultSettings {
|
||||
trash_retention: unknown;
|
||||
field_history_retention: unknown;
|
||||
generator_defaults: unknown;
|
||||
attachment_caps: unknown;
|
||||
autofill_origin_acks: Record<string, number>;
|
||||
}
|
||||
|
||||
// --- Vault config (device-local) ---
|
||||
|
||||
export interface VaultConfig {
|
||||
hostType: 'gitea' | 'github';
|
||||
hostUrl: string;
|
||||
@@ -34,20 +204,41 @@ export interface VaultConfig {
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
/// Persisted setup state in chrome.storage.local.
|
||||
export interface SetupState {
|
||||
config: VaultConfig | null;
|
||||
imageBase64: string | null;
|
||||
isConfigured: boolean;
|
||||
}
|
||||
|
||||
/// User-configurable credential capture settings.
|
||||
export interface RelicarioSettings {
|
||||
// --- Device-local UX settings (chrome.storage.local — renamed from RelicarioSettings) ---
|
||||
|
||||
export interface DeviceSettings {
|
||||
captureEnabled: boolean;
|
||||
captureStyle: 'bar' | 'toast';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: RelicarioSettings = {
|
||||
export const DEFAULT_DEVICE_SETTINGS: DeviceSettings = {
|
||||
captureEnabled: false,
|
||||
captureStyle: 'bar',
|
||||
};
|
||||
|
||||
// --- Generator request (matches Rust GeneratorRequest — tag="kind") ---
|
||||
|
||||
export type GeneratorRequest =
|
||||
| { kind: 'bip39'; word_count: number; separator: string; capitalization: Capitalization }
|
||||
| { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset };
|
||||
|
||||
export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
|
||||
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }
|
||||
export type SymbolCharset =
|
||||
| { kind: 'safe_only' }
|
||||
| { kind: 'extended' }
|
||||
| { kind: 'custom'; value: string };
|
||||
|
||||
// Default used by the α popup "gen" button:
|
||||
export const DEFAULT_PASSWORD_REQUEST: GeneratorRequest = {
|
||||
kind: 'random',
|
||||
length: 20,
|
||||
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||
symbol_charset: { kind: 'safe_only' },
|
||||
};
|
||||
|
||||
78
extension/src/wasm.d.ts
vendored
@@ -1,25 +1,65 @@
|
||||
/// Type declarations for the relicario WASM module produced by wasm-pack.
|
||||
|
||||
// Ambient module declarations for the WASM glue code.
|
||||
// The module specifier must exactly match what's used in import statements.
|
||||
// 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.
|
||||
//
|
||||
// Declared under the bare specifier 'relicario-wasm' so `typeof
|
||||
// import('relicario-wasm')` resolves in setup.ts. Webpack doesn't
|
||||
// actually resolve the module — setup.ts loads the auto-generated
|
||||
// wasm/relicario_wasm.js via a webpackIgnore dynamic import at runtime.
|
||||
|
||||
declare module 'relicario-wasm' {
|
||||
export default function init(input?: string | URL): Promise<void>;
|
||||
export function derive_master_key(
|
||||
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: bigint;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
export function unlock(
|
||||
passphrase: string,
|
||||
image_secret: Uint8Array,
|
||||
image_bytes: 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;
|
||||
): 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;
|
||||
|
||||
export default function init(module_or_path?: unknown): Promise<void>;
|
||||
export function initSync(args: { module: WebAssembly.Module }): void;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,8 @@
|
||||
"rootDir": "./src",
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"paths": {
|
||||
"relicario-wasm": ["./wasm/relicario_wasm.js"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "wasm"]
|
||||
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"]
|
||||
}
|
||||
|
||||
8
extension/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/__tests__/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||