Compare commits
193 Commits
plan-1c-be
...
8f78b6dc01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f78b6dc01 | ||
|
|
315967f4a1 | ||
|
|
b450ecd1cc | ||
|
|
e6eb698c4c | ||
|
|
8855078179 | ||
|
|
bd8102c9ad | ||
|
|
c91b31a7ca | ||
|
|
bb8b86f0d5 | ||
|
|
ed2d299a92 | ||
|
|
7bd1a9dd7d | ||
|
|
026b94092e | ||
|
|
f7e245d6b0 | ||
|
|
6cbd011705 | ||
|
|
e452d8df02 | ||
|
|
5fbdd30a19 | ||
|
|
61dbb4d3a3 | ||
|
|
8eff96da9d | ||
|
|
39ae2ecbf3 | ||
|
|
4be0bcff83 | ||
|
|
918fdef519 | ||
|
|
f872ab5183 | ||
|
|
6eeb292fd0 | ||
|
|
79b10d6a18 | ||
|
|
eb443c38b4 | ||
|
|
00da7e7931 | ||
|
|
87e63c2f77 | ||
|
|
ef7bd5b848 | ||
|
|
1454cd8165 | ||
|
|
381e8ed496 | ||
|
|
38ba31768a | ||
|
|
71ad91592d | ||
|
|
05b1fae9f4 | ||
|
|
e2260e9df4 | ||
|
|
a634b6c745 | ||
|
|
e2381ed2ec | ||
|
|
6e720554fa | ||
|
|
f0d8758a80 | ||
|
|
e5875249bf | ||
|
|
506ad9711d | ||
|
|
33b3f0b019 | ||
|
|
31672b714d | ||
|
|
f1ae5841bc | ||
|
|
9ed7e7c25b | ||
|
|
ad2c0f9e24 | ||
|
|
c7c103e4d1 | ||
|
|
cf3960186c | ||
|
|
1562a2be47 | ||
|
|
ab5a885f10 | ||
|
|
66981588e7 | ||
|
|
da6f08fa35 | ||
|
|
ecb137a120 | ||
|
|
b29a138411 | ||
|
|
fbd029e4cb | ||
|
|
1f764a4639 | ||
|
|
d6831fcfd8 | ||
|
|
2fda9e0d50 | ||
|
|
ab8839a46a | ||
|
|
6f2e868892 | ||
|
|
0841bddcb5 | ||
|
|
c4905c5ee7 | ||
|
|
16888d5a3a | ||
|
|
9ee876cc4b | ||
|
|
768f0d39a5 | ||
|
|
b7180e70f9 | ||
|
|
41043e92dc | ||
|
|
565366493d | ||
|
|
17ff79d5f6 | ||
|
|
85386eb52a | ||
|
|
218ccb8efa | ||
|
|
c1f48ecb71 | ||
|
|
419408bbad | ||
|
|
06913a0aed | ||
|
|
9ec5e9b4e1 | ||
|
|
2e825a9d33 | ||
|
|
5d9ea37b7f | ||
|
|
f32c14f939 | ||
|
|
7407fe512f | ||
|
|
6d96ca8288 | ||
|
|
536ef2464b | ||
|
|
a32f13b63a | ||
|
|
bd7bef7ce4 | ||
|
|
734325a31f | ||
|
|
7ce57353f2 | ||
|
|
b8dfcd0e97 | ||
|
|
e02f62f961 | ||
|
|
1ffe333697 | ||
|
|
e4949c4c06 | ||
|
|
0b59b94a0b | ||
|
|
08086b9a9e | ||
|
|
57dd186bab | ||
|
|
c66fd520f8 | ||
|
|
b951741366 | ||
|
|
3f0f5b1b28 | ||
|
|
f79a67bb15 | ||
|
|
a7dbf35126 | ||
|
|
086b73b260 | ||
|
|
d8a06346b9 | ||
|
|
beff092818 | ||
|
|
aa1ad99e6e | ||
|
|
2756033bf9 | ||
|
|
e79e80b000 | ||
|
|
214f8da673 | ||
|
|
3aa17e6be2 | ||
|
|
399a276fdd | ||
|
|
f44aedfa76 | ||
|
|
a182c1ac5a | ||
|
|
7fa1f2990f | ||
|
|
8e72ed8714 | ||
|
|
19bb5b5293 | ||
|
|
86b5941875 | ||
|
|
98c962796f | ||
|
|
2c94dfaf90 | ||
|
|
7588a75bdc | ||
|
|
44fc157f35 | ||
|
|
ce59223fc0 | ||
|
|
6c8ebb3548 | ||
|
|
7e0950e364 | ||
|
|
101f0093a4 | ||
|
|
86621f075f | ||
|
|
bd13854f59 | ||
|
|
5089c2b7ea | ||
|
|
9488670b1b | ||
|
|
8f603ec069 | ||
|
|
446949c5ce | ||
|
|
c59e6892d8 | ||
|
|
39db697ce5 | ||
|
|
eb14946f06 | ||
|
|
abfc5aed42 | ||
|
|
b55c59bd35 | ||
|
|
2fa54e2144 | ||
|
|
3b4788e5dc | ||
|
|
7fe54472b3 | ||
|
|
9fbf9bb3ee | ||
|
|
39a8e12438 | ||
|
|
d2cb6d8461 | ||
|
|
0003c3e658 | ||
|
|
5a001a805c | ||
|
|
caebe9f97e | ||
|
|
af050f176c | ||
|
|
3372358b31 | ||
|
|
ab36dbd31a | ||
|
|
9c481422ad | ||
|
|
705b171553 | ||
|
|
6ef7aaca53 | ||
|
|
dcb1590391 | ||
|
|
c5f0449843 | ||
|
|
b9c495cdea | ||
|
|
5217d04034 | ||
|
|
559c881dca | ||
|
|
27ca91234f | ||
|
|
dc660c4ce8 | ||
|
|
63fcfae72c | ||
|
|
511d533de0 | ||
|
|
71c182af9a | ||
|
|
f963ae33af | ||
|
|
0589fe3123 | ||
|
|
6f5ef43fe1 | ||
|
|
6904f729dc | ||
|
|
010c4263ba | ||
|
|
ac15f060e9 | ||
|
|
b03058abd9 | ||
|
|
c9cd3696ae | ||
|
|
083b01aa91 | ||
|
|
3c0f8d2c5c | ||
|
|
9add305a10 | ||
|
|
f32fe93202 | ||
|
|
bbafe7fb7e | ||
|
|
5bc75c9f8a | ||
|
|
976db85a45 | ||
|
|
61b16779ab | ||
|
|
5e04fcf1ca | ||
|
|
ae6b025435 | ||
|
|
a3f13fd2af | ||
|
|
7b5d36603b | ||
|
|
b5743efa67 | ||
|
|
4b7f1fd6d6 | ||
|
|
783cb7cc2b | ||
|
|
fba50b89e8 | ||
|
|
15fcaf9797 | ||
|
|
531af03ff1 | ||
|
|
8a16482b9c | ||
|
|
af432de320 | ||
|
|
025629cacf | ||
|
|
e47945d86a | ||
|
|
b52e49a51e | ||
|
|
6ba9ccfa4c | ||
|
|
e1d32b0379 | ||
|
|
3264cccb60 | ||
|
|
553d9d7ca9 | ||
|
|
3f12543c81 | ||
|
|
2ca563a8cd | ||
|
|
62112f50f9 | ||
|
|
81fbe132ad |
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
128
CHANGELOG.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Sync now button** in the extension settings view — surfaces the
|
||||||
|
previously hidden `{ type: 'sync' }` SW message to users with success /
|
||||||
|
error feedback.
|
||||||
|
- **Device registration from the popup.** The "Register this device"
|
||||||
|
button on the devices view now opens an inline name input and (on
|
||||||
|
confirm) generates a keypair via WASM, persists the private key + name
|
||||||
|
locally, and writes the device to the remote — no setup-wizard detour.
|
||||||
|
Backed by a new `register_this_device` SW message.
|
||||||
|
- **`relicario settings generator-defaults`** — view-and-edit access to the
|
||||||
|
generator defaults stored in `VaultSettings`. Flags: `--random` /
|
||||||
|
`--bip39` to switch mode, `--length`, `--words`, `--symbols`,
|
||||||
|
`--separator` to update fields of the active mode.
|
||||||
|
- **`relicario edit` now supports TOTP items.** Issuer, label, and secret
|
||||||
|
rotation work; rotated secrets are pushed to `field_history` (key:
|
||||||
|
`core:totp_secret`).
|
||||||
|
- **`relicario history <query>`** — view captured field history. Values
|
||||||
|
are masked by default; `--show` reveals them; `--field <name>` filters
|
||||||
|
to one synthetic key (e.g. `login_password`, `totp_secret`).
|
||||||
|
- **`relicario detach <query> <aid>`** — remove an individual attachment
|
||||||
|
from an item. Refuses to drop a Document item's primary attachment
|
||||||
|
(use `purge` instead).
|
||||||
|
- **`relicario status`** — vault summary: root path, item count
|
||||||
|
(active / trashed), attachment count + total bytes, registered device
|
||||||
|
count, last commit (`%h %s`).
|
||||||
|
- **Backup & restore.** New `relicario backup export <out.relbak>` and
|
||||||
|
`relicario backup restore <in.relbak> [<dir>]` commands. The `.relbak`
|
||||||
|
format is a single encrypted file: Argon2id-derived key from a
|
||||||
|
user-chosen backup passphrase (independent of the vault factor),
|
||||||
|
XChaCha20-Poly1305 ciphertext, zstd-compressed JSON envelope.
|
||||||
|
Reference image and `.git/` history are opt-in inclusions
|
||||||
|
(`--include-image`, `--no-history`).
|
||||||
|
- **Vault-tab Backup & Restore panel.** Export downloads the
|
||||||
|
`.relbak` via `chrome.downloads`. Restore takes a file + backup
|
||||||
|
passphrase + new-remote config and writes the vault into a fresh
|
||||||
|
empty repo (refuses to clobber existing). Git history is never
|
||||||
|
bundled from the extension — CLI is the source of full backups.
|
||||||
|
- **LastPass CSV import.** New `relicario import lastpass <csv>`
|
||||||
|
command + vault-tab Import panel (`vault.html#import`).
|
||||||
|
Logins map to `Login` items; rows with `url == "http://sn"`
|
||||||
|
map to `SecureNote` (extra column → body verbatim, structured
|
||||||
|
data preserved as-is for manual re-categorization). TOTP
|
||||||
|
secrets in the `totp` column are base32-decoded into
|
||||||
|
`LoginCore.totp`; bad base32 surfaces a warning and the login
|
||||||
|
is imported without TOTP. Failed rows (missing `name`, missing
|
||||||
|
password on a login) are skipped with a per-row warning.
|
||||||
|
Each row gets a freshly-minted ID — re-running the import
|
||||||
|
creates duplicates rather than corrupting state.
|
||||||
|
- **Popup deep link to the Import panel.** `settings-vault`
|
||||||
|
gains an "import" section with a `LastPass CSV →` button
|
||||||
|
next to the existing `Backup & restore →` button.
|
||||||
|
- **`relicario status` shows last export age.** New `Last export:
|
||||||
|
<human-readable>` line reading `.relicario/last_backup` (a marker
|
||||||
|
file `cmd_backup_export` writes on success). Reads "never" for
|
||||||
|
fresh vaults, "4 days ago" otherwise.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
- **Mid-restore failure leaves the target remote in a half-written
|
||||||
|
state.** `cmd_backup_restore` and the vault-tab Restore panel both
|
||||||
|
write artifacts sequentially via `writeFileCreateOnly`. If the
|
||||||
|
process is interrupted partway, a retry against the same remote
|
||||||
|
refuses to clobber. Workaround: delete the partial repo and retry.
|
||||||
|
- **Cross-tool backup compatibility.** CLI-exported backups stored
|
||||||
|
attachments at `<item_id>/<aid>.enc`; extension stores at flat
|
||||||
|
`<aid>.bin`. The `.relbak` envelope canonicalizes to `<item_id>/<aid>`
|
||||||
|
keys and each tool translates at the boundary. Round-trip works in
|
||||||
|
both directions.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
|
||||||
|
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
||||||
|
extraction; behavior unchanged. The dispatcher matches and delegates.
|
||||||
|
- Extracted pure helpers (`escapeHtml`, `ratePassphrase`, `scheduleRate`,
|
||||||
|
`entropyText`, `STRENGTH_LABELS`) from `extension/src/setup/setup.ts`
|
||||||
|
into `setup-helpers.ts`. State-coupled `updateStrengthUi` stays in
|
||||||
|
`setup.ts` since it walks live wizard state. Setup.ts went from
|
||||||
|
1205 → 1137 lines.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `relicario generate` now consults `VaultSettings.generator_defaults` when
|
||||||
|
invoked inside an initialized vault. Explicit flags (`--length`,
|
||||||
|
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
|
||||||
|
default. Outside a vault, behavior is unchanged (length 20, safe symbol
|
||||||
|
set, 5 BIP39 words, space separator).
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-04-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Setup wizard could silently overwrite an existing vault.** Pointing the
|
||||||
|
wizard at a remote that already contained a Relicario vault would clobber
|
||||||
|
`manifest.enc`, `.relicario/salt`, and friends with no warning. The wizard
|
||||||
|
now probes the remote after the connection test and refuses to create a
|
||||||
|
new vault on top of an existing one. Affected users whose vault was wiped
|
||||||
|
by this bug should restore from the git history of the affected repo
|
||||||
|
(`git log` + `git checkout <pre-init-sha> -- .`).
|
||||||
|
- **New devices registered during initial setup were silently dropped.** The
|
||||||
|
wizard's Step 5 fired `add_device` over a service-worker channel that
|
||||||
|
required an unlocked vault, which is unavailable mid-wizard. Device pubkeys
|
||||||
|
now write directly to `.relicario/devices.json` from the wizard.
|
||||||
|
- **Wizard-created vaults were missing `settings.enc`.** The CLI's `init`
|
||||||
|
writes a default-`VaultSettings` `settings.enc` alongside `manifest.enc`,
|
||||||
|
but the wizard skipped it, causing every `get_vault_settings` SW call to
|
||||||
|
404. The wizard now encrypts and writes `settings.enc` using a new
|
||||||
|
`default_vault_settings_json` WASM helper that keeps defaults in sync
|
||||||
|
with Rust core.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Attach this device to an existing vault — purely from the GUI.** New
|
||||||
|
Step 0 mode picker splits the wizard into "create new vault" and "attach
|
||||||
|
this device." The attach path takes a passphrase + reference image, fetches
|
||||||
|
the existing manifest, verifies the credentials by decrypting it, and only
|
||||||
|
then registers a new device key. No CLI required for multi-device setup.
|
||||||
|
- `GitHost.lastCommit(path)` and `GitHost.writeFileCreateOnly(path, ...)`.
|
||||||
|
- `default_vault_settings_json()` WASM export.
|
||||||
|
|
||||||
|
## v0.1.0 — 2026-04-22
|
||||||
|
|
||||||
|
Initial release.
|
||||||
11
CLAUDE.md
@@ -1,8 +1,15 @@
|
|||||||
# CLAUDE.md — relicario
|
# CLAUDE.md — Relicario
|
||||||
|
|
||||||
|
## Working with the user
|
||||||
|
|
||||||
|
- **Default to "yes" / the recommended option.** When asking the user a multiple-choice or yes/no decision, pick the recommended answer and proceed without prompting. Optional follow-ups in checklists: do them. Subagent dispatch / running tests / writing code: proceed without checking.
|
||||||
|
- **Always pause and ask** before: `rm`, `rm -rf`, `git push --force`, `git reset --hard`, `git branch -D`, deleting files via Bash, dropping tables, force-pushing to main. The system-prompt's "executing actions with care" guidance still applies — this preference does not override that.
|
||||||
|
- This rule does not override genuine intent-discovery: brainstorming-skill clarifying questions about *what to build* still need user input, because picking a default would mean designing the wrong product.
|
||||||
|
- **Sprinkle Mexican Spanish into replies.** Drop 1–2 Spanish words, slang, exclamations, or idioms per reply (replies only — never in code, file contents, commit messages, or other project artifacts), each followed by `[translation]` in square brackets. Mexican flavor is preferred: ¡órale! [alright!], ¡híjole! [yikes!], ¿qué onda? [what's up?], chido [cool], ahorita [right now / in a bit], no manches [no way], ni modo [oh well], no hay bronca [no problem], ¡ya estuvo! [it's done], etc. Skip in one-word acknowledgements where the flourish would feel awkward.
|
||||||
|
|
||||||
## What is this
|
## What is this
|
||||||
|
|
||||||
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.
|
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
|
## Build and test
|
||||||
|
|
||||||
|
|||||||
232
Cargo.lock
generated
@@ -27,6 +27,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -162,6 +168,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64ct"
|
name = "base64ct"
|
||||||
version = "1.8.3"
|
version = "1.8.3"
|
||||||
@@ -269,6 +281,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -349,6 +363,15 @@ dependencies = [
|
|||||||
"strsim",
|
"strsim",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -429,6 +452,27 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "curve25519-dalek"
|
name = "curve25519-dalek"
|
||||||
version = "4.1.3"
|
version = "4.1.3"
|
||||||
@@ -645,6 +689,17 @@ version = "0.2.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"libredox",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -709,6 +764,34 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "g2gen"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc"
|
||||||
|
dependencies = [
|
||||||
|
"g2poly",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "g2p"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad"
|
||||||
|
dependencies = [
|
||||||
|
"g2gen",
|
||||||
|
"g2poly",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "g2poly"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -742,6 +825,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi 5.3.0",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -750,7 +845,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -772,6 +867,8 @@ version = "0.15.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1002,6 +1099,16 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.95"
|
version = "0.3.95"
|
||||||
@@ -1044,7 +1151,10 @@ version = "0.1.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
|
"plain",
|
||||||
|
"redox_syscall 0.7.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1074,6 +1184,15 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -1262,7 +1381,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall 0.5.18",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -1300,6 +1419,18 @@ dependencies = [
|
|||||||
"spki",
|
"spki",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plain"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1403,6 +1534,15 @@ version = "0.1.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
dependencies = [
|
||||||
|
"image",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1418,6 +1558,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1463,6 +1609,15 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -1505,24 +1660,28 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"dirs",
|
"dirs",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"hex",
|
"hex",
|
||||||
"image",
|
"image",
|
||||||
"predicates",
|
"predicates",
|
||||||
|
"qrcode",
|
||||||
"rand",
|
"rand",
|
||||||
"relicario-core",
|
"relicario-core",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
|
"rqrr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"url",
|
"url",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -1530,12 +1689,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"base64",
|
||||||
"bip39",
|
"bip39",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"csv",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -1546,19 +1707,25 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"tar",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
"url",
|
"url",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
"zstd",
|
||||||
"zxcvbn",
|
"zxcvbn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
|
"hex",
|
||||||
"image",
|
"image",
|
||||||
|
"rand",
|
||||||
"relicario-core",
|
"relicario-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
@@ -1579,6 +1746,17 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rqrr"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad0cd0432e6beb2f86aa4c8af1bb5edcf3c9bcb9d4836facc048664205458575"
|
||||||
|
dependencies = [
|
||||||
|
"g2p",
|
||||||
|
"image",
|
||||||
|
"lru",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rtoolbox"
|
name = "rtoolbox"
|
||||||
version = "0.0.5"
|
version = "0.0.5"
|
||||||
@@ -1617,6 +1795,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -1797,6 +1981,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -2700,6 +2894,34 @@ version = "1.0.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.16+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="extension/icons/relicario-logo.svg" alt="relicario" width="128" height="128">
|
<img src="extension/icons/relicario-logo.svg" alt="Relicario" width="128" height="128">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# relicario
|
# 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.
|
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.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Your reference photo (something you have)
|
|||||||
your device (opaque ciphertext)
|
your device (opaque ciphertext)
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
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.
|
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.
|
||||||
|
|
||||||
@@ -58,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 |
|
| LastPass | ~40-60 bits (master password only) | 1 |
|
||||||
| Bitwarden | ~40-60 bits (master password only) | 1 |
|
| Bitwarden | ~40-60 bits (master password only) | 1 |
|
||||||
| 1Password | password + 128-bit Secret Key | 2 |
|
| 1Password | password + 128-bit Secret Key | 2 |
|
||||||
| **relicario** | **password + 256-bit image secret** | **2** |
|
| **Relicario** | **password + 256-bit image secret** | **2** |
|
||||||
|
|
||||||
### What we don't protect against
|
### What we don't protect against
|
||||||
|
|
||||||
|
|||||||
539
crates/relicario-cli/ARCHITECTURE.md
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
# Architecture: relicario-cli
|
||||||
|
|
||||||
|
## What this crate is for
|
||||||
|
|
||||||
|
The `relicario` binary is the platform layer for `relicario-core`: it adds
|
||||||
|
filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts,
|
||||||
|
clipboard handoff, and a clap-based command surface. The crate has two design
|
||||||
|
roles. First, it is the developer / power-user surface that exposes everything
|
||||||
|
the core can do (every `ItemCore` variant, every `VaultSettings` knob, history
|
||||||
|
inspection, device key management). Second, it is the only working interface
|
||||||
|
during disaster recovery — the extension may be uninstalled, the device may be
|
||||||
|
new — so it intentionally maintains feature parity with the extension's vault
|
||||||
|
tab. It deliberately shells out to `git` rather than depending on libgit2 /
|
||||||
|
gitoxide; this keeps the dep tree slim, lets the user override `git` config
|
||||||
|
locally, and lets recovery debugging happen with familiar tooling.
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
The crate is three files of source and a `tests/` directory. Each source file
|
||||||
|
has one job.
|
||||||
|
|
||||||
|
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
|
||||||
|
handler. Internal structure: a top-level `Cli` / `Commands` enum
|
||||||
|
(`main.rs:13-275`), a flat dispatcher `match` in `main()`
|
||||||
|
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
|
||||||
|
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
|
||||||
|
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
|
||||||
|
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
|
||||||
|
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
|
||||||
|
can be tested through the same integration paths. Owns all clap argument
|
||||||
|
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
|
||||||
|
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
|
||||||
|
`commit_paths` helper that is the single chokepoint for git commits during
|
||||||
|
vault mutations.
|
||||||
|
|
||||||
|
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
|
||||||
|
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
|
||||||
|
key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the
|
||||||
|
`unlock_interactive` flow (vault root walk → salt read → params read →
|
||||||
|
reference image extract → passphrase prompt → KDF) at `session.rs:33-59`,
|
||||||
|
the typed `load_*` / `save_*` accessors for `Item` / `Manifest` /
|
||||||
|
`VaultSettings`, the `read_salt` / `read_params` helpers, the
|
||||||
|
`RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which
|
||||||
|
every disk write to a vault file goes through. Owns the env-var escape
|
||||||
|
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
|
||||||
|
(`session.rs:125`) that integration tests use to bypass the TTY.
|
||||||
|
|
||||||
|
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
||||||
|
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
||||||
|
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
|
||||||
|
for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the
|
||||||
|
hardened-`git` factory that every git invocation in the crate (production
|
||||||
|
code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix
|
||||||
|
seconds for human-readable output (audit M11). The hardening is
|
||||||
|
load-bearing — see Invariants & Gotchas below.
|
||||||
|
|
||||||
|
## Invariants & contracts
|
||||||
|
|
||||||
|
These are the load-bearing rules the crate relies on. Each has been verified
|
||||||
|
in code; cite the line if you change it.
|
||||||
|
|
||||||
|
- **Every vault-mutating command unlocks via `UnlockedVault`.** The struct
|
||||||
|
holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on
|
||||||
|
scope exit (`session.rs:22-25`). No command bypasses this except
|
||||||
|
`cmd_generate` outside a vault dir and `cmd_init` (which derives the key
|
||||||
|
inline before there is a vault to unlock).
|
||||||
|
|
||||||
|
- **Every `git` invocation in production code goes through
|
||||||
|
`helpers::git_command`.** A grep for `Command::new("git")` outside
|
||||||
|
`helpers.rs` finds zero hits in `src/`; the only other match is in
|
||||||
|
`tests/edit_and_history.rs:18`, which is test-side verification of the git
|
||||||
|
log and is exempt by design. `git_command` injects
|
||||||
|
`core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true`
|
||||||
|
via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would
|
||||||
|
bypass the hardening — don't.
|
||||||
|
|
||||||
|
- **Every file write to a vault file uses `atomic_write`.** `atomic_write`
|
||||||
|
(`session.rs:144-151`) writes `<path>.tmp` then renames over `<path>`; a
|
||||||
|
partial write never appears as the live file. All `UnlockedVault::save_*`
|
||||||
|
helpers route through it. (`cmd_init` writes pre-creation files via
|
||||||
|
`fs::write` at `main.rs:373-393`; that path doesn't need atomicity because
|
||||||
|
the vault doesn't exist yet — failure leaves a half-built vault that the
|
||||||
|
next run rejects via `relicario_dir.exists()` at `main.rs:326`.)
|
||||||
|
|
||||||
|
- **Every commit during a mutating command uses `commit_paths`.**
|
||||||
|
`commit_paths` (`main.rs:767-775`) does `git add <paths> && git commit -m
|
||||||
|
<msg>` through the hardened wrapper. Commit message convention is
|
||||||
|
`<verb>: <title> (<id>)` — `add:`, `edit:`, `trash:`, `restore:`, `purge:`,
|
||||||
|
`attach:`, `detach:`, `settings: update`, `device: add <name>`, `device:
|
||||||
|
revoke <name>`, `init: new relicario vault (format v2)`, `trash empty:
|
||||||
|
purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use
|
||||||
|
`git_command` directly (not `commit_paths`) because they need a slightly
|
||||||
|
different add/commit pattern; they still go through the hardened wrapper.
|
||||||
|
|
||||||
|
- **`cmd_generate` is the only command that runs without unlock — and only
|
||||||
|
when invoked outside a vault directory.** Inside a vault,
|
||||||
|
`cmd_generate` unlocks to read `settings.generator_defaults`
|
||||||
|
(`main.rs:1440-1445`); explicit flags override the stored defaults. This is
|
||||||
|
why the smoke-test `cargo run -p relicario-cli -- generate --length 32`
|
||||||
|
works without any setup.
|
||||||
|
|
||||||
|
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
||||||
|
directly; `Item::new` (called inside every `build_*_item`) does it via
|
||||||
|
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
||||||
|
|
||||||
|
- **Manifest is always saved last.** Within a single command, the order is:
|
||||||
|
write item file → mutate manifest → save manifest → commit. If the process
|
||||||
|
dies between step 1 and step 3, the next run sees an item file with no
|
||||||
|
manifest entry; `cmd_status` / `cmd_list` ignore it because they read the
|
||||||
|
manifest, not the directory. (Recovery would manually re-`add` to surface
|
||||||
|
it.)
|
||||||
|
|
||||||
|
- **Vault root is always discovered, never assumed to be `cwd`.**
|
||||||
|
`helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any
|
||||||
|
command run from a subdirectory of the vault works (verified by
|
||||||
|
`vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally
|
||||||
|
rejected because they don't contain `.relicario/` — no compat shim needed
|
||||||
|
(`vault_detection.rs:42-59`).
|
||||||
|
|
||||||
|
- **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to
|
||||||
|
`rpassword`.** This is the only way integration tests can drive the
|
||||||
|
per-item secret prompts (Login password, Card number, TOTP secret rotation,
|
||||||
|
Key material) without a real TTY. The check is at `main.rs:308-313`.
|
||||||
|
|
||||||
|
## Key flows
|
||||||
|
|
||||||
|
### Vault init (`cmd_init`, `main.rs:315-418`)
|
||||||
|
|
||||||
|
1. Refuse if `.relicario/` already exists (`main.rs:326-328`).
|
||||||
|
2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm
|
||||||
|
they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail
|
||||||
|
with audit-H3 message on weak input (`main.rs:331-348`).
|
||||||
|
3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the
|
||||||
|
carrier JPEG via `imgsecret::embed`, write the stego output to `--output`
|
||||||
|
(`main.rs:351-360`).
|
||||||
|
4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3,
|
||||||
|
argon2_p: 4 }` (production-grade) at `main.rs:363-365`.
|
||||||
|
5. `derive_master_key(passphrase, image_secret, salt, params)` →
|
||||||
|
`Zeroizing<[u8;32]>` (`main.rs:368`).
|
||||||
|
6. Create `.relicario/`, `items/`, `attachments/` dirs; write
|
||||||
|
`.relicario/{salt, params.json, devices.json}`; encrypt and write
|
||||||
|
`manifest.enc` (empty `Manifest::new()`) and `settings.enc`
|
||||||
|
(`VaultSettings::default()`) (`main.rs:370-393`).
|
||||||
|
7. Write `.gitignore` listing the reference image filename (so the second
|
||||||
|
factor never accidentally ends up in git) (`main.rs:396-400`).
|
||||||
|
8. `git init` then initial commit `init: new relicario vault (format v2)`
|
||||||
|
via `git_command` (`main.rs:403-412`). Note the initial commit does NOT
|
||||||
|
go through `commit_paths` — it precedes the existence of an
|
||||||
|
`UnlockedVault`, so the path list is hand-spelled.
|
||||||
|
|
||||||
|
### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`)
|
||||||
|
|
||||||
|
1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the
|
||||||
|
"run `relicario init` first" message on miss (`helpers.rs:21-26`).
|
||||||
|
2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length).
|
||||||
|
3. `read_params` deserializes `.relicario/params.json` and extracts the
|
||||||
|
nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested
|
||||||
|
shape exists because `params.json` also stores `format_version`, `aead`,
|
||||||
|
and `salt_path` for forward-compat probing.
|
||||||
|
4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg`
|
||||||
|
convention, then prompts (`session.rs:124-140`).
|
||||||
|
5. Read the reference image bytes; `imgsecret::extract` runs the DCT
|
||||||
|
majority-vote decode to recover the 32-byte image secret
|
||||||
|
(`session.rs:38-40`).
|
||||||
|
6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword`
|
||||||
|
(`session.rs:42-49`).
|
||||||
|
7. `derive_master_key` produces the master key; `UnlockedVault { root,
|
||||||
|
master_key }` is returned and lives until the command function returns.
|
||||||
|
|
||||||
|
### Item add (`cmd_add`, `main.rs:419-456`)
|
||||||
|
|
||||||
|
1. Unlock the vault and load the manifest.
|
||||||
|
2. Match on the `AddKind` variant and dispatch to the matching
|
||||||
|
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
|
||||||
|
builders; only `build_document_item` takes `&UnlockedVault` because it
|
||||||
|
needs `attachment_caps` and writes the encrypted blob alongside the item.
|
||||||
|
3. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||||
|
favorite-flag, primary attachment if any).
|
||||||
|
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||||
|
`vault.save_manifest(&manifest)`.
|
||||||
|
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||||
|
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
||||||
|
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
||||||
|
|
||||||
|
### Item edit (`cmd_edit`, `main.rs:938-977`)
|
||||||
|
|
||||||
|
1. Unlock, load manifest, resolve query → item id, load the item.
|
||||||
|
2. Universally-editable fields (title, group, tags) are prompted via
|
||||||
|
`prompt_keep` / `prompt_keep_opt` first; blank input keeps the current
|
||||||
|
value (`main.rs:952-956`).
|
||||||
|
3. Borrow `&mut item.field_history` once into a local `history` binding
|
||||||
|
(`main.rs:958`), then `match` on `&mut item.core` and dispatch to the
|
||||||
|
per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking
|
||||||
|
editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`,
|
||||||
|
`edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`,
|
||||||
|
`edit_document_message`) don't.
|
||||||
|
4. Each editor that mutates a tracked secret calls `push_history(history,
|
||||||
|
"<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below
|
||||||
|
for the synthetic-key convention.
|
||||||
|
5. `item.modified = now_unix()`, save, upsert manifest, commit
|
||||||
|
`edit: <title> (<id>)`.
|
||||||
|
|
||||||
|
`edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` /
|
||||||
|
`extract` instead" — Document items can't be field-edited; they're
|
||||||
|
attachment-shaped.
|
||||||
|
|
||||||
|
The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it
|
||||||
|
exists so the editor signatures don't have to spell out the full
|
||||||
|
`HashMap<FieldId, Vec<FieldHistoryEntry>>`.
|
||||||
|
|
||||||
|
### History capture and view (`push_history` + `cmd_history`)
|
||||||
|
|
||||||
|
`push_history` (`main.rs:1095-1109`) records an old value under a synthetic
|
||||||
|
`FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so
|
||||||
|
they can never collide with real custom-field UUIDs from the typed-item
|
||||||
|
custom-fields work. The keys used in the codebase are:
|
||||||
|
|
||||||
|
- `core:login_password` (`main.rs:998`)
|
||||||
|
- `core:secure_note_body` (`main.rs:1012`)
|
||||||
|
- `core:card_number` (`main.rs:1031`)
|
||||||
|
- `core:key_material` (`main.rs:1045`)
|
||||||
|
- `core:totp_secret` (`main.rs:1063`)
|
||||||
|
|
||||||
|
`cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the
|
||||||
|
keys, strips the `core:` prefix for display, and prints each entry list
|
||||||
|
masked or revealed depending on `--show`. The `--field <name>` filter
|
||||||
|
matches against either the stripped name (`login_password`) or the raw key
|
||||||
|
(`core:login_password`) so both forms work (`main.rs:1126-1129`). The
|
||||||
|
`relicario history bank --field totp_secret` form is what
|
||||||
|
`edit_and_history.rs` exercises.
|
||||||
|
|
||||||
|
### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`)
|
||||||
|
|
||||||
|
- `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets
|
||||||
|
`trashed_at`), saves, upserts manifest, commits `trash:`.
|
||||||
|
- `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`,
|
||||||
|
same wrap-up, commit `restore:`.
|
||||||
|
- `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`)
|
||||||
|
which removes the item file, the attachment dir, the manifest entry, and
|
||||||
|
`git rm -rf --ignore-unmatch`s the paths. Then a single `git add
|
||||||
|
manifest.enc` + commit `purge: <title> (<id>)`.
|
||||||
|
- `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating
|
||||||
|
command. It loads settings once, iterates all items past their
|
||||||
|
`trash_retention` window, calls `purge_item` for each, then does a single
|
||||||
|
`git add manifest.enc` + commit `trash empty: purged N item(s)`. The
|
||||||
|
single-unlock-per-batch shape was the fix in commit `b5015b3` — the
|
||||||
|
earlier version re-prompted for the passphrase per item.
|
||||||
|
|
||||||
|
### Attach / detach / extract
|
||||||
|
|
||||||
|
- `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings
|
||||||
|
and rejects if the item has hit `per_item_max_count`. `encrypt_attachment`
|
||||||
|
enforces `per_attachment_max_bytes`. The encrypted blob lands at
|
||||||
|
`attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core.
|
||||||
|
Commit message: `attach: <file> → <title> (<id>)`.
|
||||||
|
- `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one
|
||||||
|
attachment from the item, deletes the encrypted blob, rewrites the item.
|
||||||
|
Refuses if the target `aid` is a `Document` item's `primary_attachment`
|
||||||
|
(`main.rs:1392-1400`) — that would orphan the item; use `purge` instead.
|
||||||
|
Commit message: `detach: <filename> from <title> (<id>)`.
|
||||||
|
- `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the
|
||||||
|
plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no
|
||||||
|
state mutation.
|
||||||
|
- `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename
|
||||||
|
— read-only.
|
||||||
|
|
||||||
|
### Generate (`cmd_generate`, `main.rs:1426-1489`)
|
||||||
|
|
||||||
|
Has two distinct modes:
|
||||||
|
|
||||||
|
- **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays
|
||||||
|
`None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`,
|
||||||
|
`words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt.
|
||||||
|
- **Inside a vault** — `vault_dir()` succeeds; full unlock; load
|
||||||
|
`settings.generator_defaults`. Explicit flags override the stored defaults
|
||||||
|
field-by-field. `--bip39` flips mode; absent that flag, the mode is
|
||||||
|
whatever the stored default is. Tests:
|
||||||
|
`settings.rs::generate_uses_vault_default_length` (length-tracking) and
|
||||||
|
`basic_flows.rs::generate_random_and_bip39` (no-vault smoke).
|
||||||
|
|
||||||
|
The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is
|
||||||
|
the only command outside `cmd_init` that touches `helpers::vault_dir()`
|
||||||
|
directly instead of going through `UnlockedVault::unlock_interactive()`.
|
||||||
|
|
||||||
|
### Sync (`cmd_sync`, `main.rs:1582-1590`)
|
||||||
|
|
||||||
|
`git pull --rebase` then `git push`, both via the hardened wrapper. No
|
||||||
|
unlock — sync moves opaque ciphertext, the master key is never needed. This
|
||||||
|
is the only command that fails on conflict; it doesn't try to resolve.
|
||||||
|
Resolution happens manually in the user's git tooling.
|
||||||
|
|
||||||
|
### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`)
|
||||||
|
|
||||||
|
Unlocks; loads manifest; counts items (active vs trashed), attachments
|
||||||
|
(count + total bytes), devices (parsed from `devices.json`); shells out to
|
||||||
|
`git log -1 --pretty=format:%h %s` for the last-commit summary line. All
|
||||||
|
read-only — no commit, no state change.
|
||||||
|
|
||||||
|
### Device management (`cmd_device`, `main.rs:1632-1702`)
|
||||||
|
|
||||||
|
Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to
|
||||||
|
`.relicario/devices.json`, write the secret signing key to
|
||||||
|
`<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit
|
||||||
|
`device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name,
|
||||||
|
rewrite `devices.json`, commit `device: revoke <name>`. Note that device
|
||||||
|
keys are kept entirely separate from the KDF (passphrase × image stays
|
||||||
|
unchanged across device add/revoke), as per the design spec.
|
||||||
|
|
||||||
|
### Backup-passphrase-style commands (none yet)
|
||||||
|
|
||||||
|
The import / export / `import-lastpass` commands described in
|
||||||
|
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
|
||||||
|
not yet implemented. When they land they'll fit in the dispatcher
|
||||||
|
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here
|
||||||
|
until that work begins.
|
||||||
|
|
||||||
|
## Cross-cutting concerns
|
||||||
|
|
||||||
|
- **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors
|
||||||
|
bubble up through `?` from `RelicarioError`. Per-step context is added
|
||||||
|
via `with_context(|| ...)` chains, e.g. `format!("failed to read {}",
|
||||||
|
path.display())`. AEAD authentication failures intentionally surface as
|
||||||
|
the ambiguous "wrong passphrase or corrupt vault" message from core — the
|
||||||
|
CLI does not differentiate. clap argument errors are produced by clap
|
||||||
|
(e.g., `--days` and `--forever` together fail at the
|
||||||
|
`SettingsAction::TrashRetention` arm in `main.rs:1504-1510`).
|
||||||
|
|
||||||
|
- **Atomicity.** Every disk write to a vault file goes through
|
||||||
|
`session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then
|
||||||
|
rename over `<path>`. Manifest is the single source of truth and is
|
||||||
|
always written *last* in any multi-file operation, so a process kill
|
||||||
|
between item-write and manifest-write leaves an orphan item file (which
|
||||||
|
doesn't appear in `list`/`status`) but never a manifest pointing to a
|
||||||
|
missing file.
|
||||||
|
|
||||||
|
- **Git history as audit log.** Per-action commits, never amended, never
|
||||||
|
squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`,
|
||||||
|
`restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`,
|
||||||
|
`init:`) makes `git log --oneline` a literal audit trail. Tests verify
|
||||||
|
this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`).
|
||||||
|
|
||||||
|
- **Where secrets live.**
|
||||||
|
- Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>`
|
||||||
|
(`session.rs:24`). Wipes on drop.
|
||||||
|
- Image secret — `Zeroizing<[u8; 32]>`, lives only inside
|
||||||
|
`unlock_interactive` until the KDF call (`session.rs:40`).
|
||||||
|
- Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or
|
||||||
|
the env var (`session.rs:42-49`, `main.rs:333-342`).
|
||||||
|
- Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`,
|
||||||
|
`Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and
|
||||||
|
`Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from
|
||||||
|
base32). All flow through core types.
|
||||||
|
- Clipboard copy — `Zeroizing<String>` cloned into the detached 30s
|
||||||
|
auto-clear thread (`main.rs:873-889`).
|
||||||
|
|
||||||
|
- **Test escape hatches.** Three env vars exist for integration tests; all
|
||||||
|
are read at exactly one site each:
|
||||||
|
- `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and
|
||||||
|
`main.rs:333,338` (init).
|
||||||
|
- `RELICARIO_IMAGE` — `session.rs:125` (image path resolution).
|
||||||
|
- `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only).
|
||||||
|
None of them have a production fall-through; absent the var, the code
|
||||||
|
always prompts. They are safe in production binaries because the user
|
||||||
|
would have to set them explicitly.
|
||||||
|
|
||||||
|
- **Generate-without-unlock is intentional.** It is NOT an oversight.
|
||||||
|
`relicario generate --length 32` is the documented smoke test (see the
|
||||||
|
repo's CLAUDE.md) and works as a standalone CSPRNG password generator
|
||||||
|
outside any vault. Inside a vault it does require unlock — see Gotchas.
|
||||||
|
|
||||||
|
## Test architecture
|
||||||
|
|
||||||
|
All tests are integration tests; there are no `#[cfg(test)]` modules in
|
||||||
|
`src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests
|
||||||
|
(`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601`
|
||||||
|
formatting in isolation. Everything else is `tests/`.
|
||||||
|
|
||||||
|
- **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()`
|
||||||
|
spins up a fresh `TempDir`, generates a 400×300 JPEG via
|
||||||
|
`make_test_jpeg()` (deterministic noise; no binary fixtures), runs
|
||||||
|
`relicario init --image carrier.jpg --output reference.jpg` with
|
||||||
|
`RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference
|
||||||
|
image path on the struct. `run` and `run_with_input` are the two ways to
|
||||||
|
invoke the binary against the test vault: both inherit
|
||||||
|
`RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra
|
||||||
|
newlines into stdin (used for interactive prompts that aren't
|
||||||
|
`rpassword`-driven). The note at the top warns Task 23 implementers
|
||||||
|
about the new-item-password rpassword path; the fix landed as
|
||||||
|
`RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`.
|
||||||
|
|
||||||
|
- **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout
|
||||||
|
(`.relicario/{salt,params.json,devices.json}`, `manifest.enc`,
|
||||||
|
`settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json`
|
||||||
|
v2 shape; `add login` + `list`; `get` masking semantics (with and
|
||||||
|
without `--show`); the rm/restore/purge cycle including `list --trashed`;
|
||||||
|
and the two-mode `generate` smoke (random length + bip39 word count) run
|
||||||
|
outside a vault.
|
||||||
|
|
||||||
|
- **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end
|
||||||
|
by piping stdin lines (blank to keep, `y` to confirm) plus
|
||||||
|
`RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*`
|
||||||
|
verifies the item file is rewritten and the `edit: bank` commit lands.
|
||||||
|
The four `history_command_*` tests cover masked listing, `--show`
|
||||||
|
reveal, "no history captured" output, and per-field filtering. The
|
||||||
|
`edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27
|
||||||
|
in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the
|
||||||
|
full TOTP edit including issuer / label / secret rotation.
|
||||||
|
|
||||||
|
- **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/
|
||||||
|
`extract` round-trip (verifies the bytes survive the encrypt-decrypt
|
||||||
|
hop); `detach` removes both the attachment ref and the encrypted blob
|
||||||
|
on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads
|
||||||
|
over `per_attachment_max_bytes`. The detach test (`detach_*`) and the
|
||||||
|
cap test were added in `3f0f5b1` / `20350d5` respectively.
|
||||||
|
|
||||||
|
- **`tests/settings.rs`** (`135 lines`) — `settings show` and
|
||||||
|
`settings trash-retention --days 60` round-trip; the conflicting-flags
|
||||||
|
rejection (`--days` + `--forever`); the
|
||||||
|
`generate_uses_vault_default_length` test that verifies (a) default
|
||||||
|
vault length is 20, (b) updating `settings generator-defaults --length
|
||||||
|
32` changes the default, (c) explicit `--length 8` overrides the stored
|
||||||
|
default; the multi-shape `cmd_status` smoke; and the
|
||||||
|
`generate_works_outside_vault` test that verifies the no-unlock path
|
||||||
|
works in a bare `TempDir` with no `.relicario/`.
|
||||||
|
|
||||||
|
- **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit
|
||||||
|
L8: `list` refuses without a marker; `list` from a nested subdirectory
|
||||||
|
finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected
|
||||||
|
with the `.relicario` hint in the error message.
|
||||||
|
|
||||||
|
The whole test suite uses `assert_cmd` to spawn the real binary against a
|
||||||
|
real temp directory, so they exercise actual fs / git / KDF code paths.
|
||||||
|
The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in
|
||||||
|
the test path (`main.rs:365`), which is why init takes a noticeable beat
|
||||||
|
in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note
|
||||||
|
applies to `relicario-core` unit tests, not these CLI integration tests.
|
||||||
|
|
||||||
|
## Gotchas & non-obvious decisions
|
||||||
|
|
||||||
|
- **`cmd_generate` runs without unlock outside a vault, but with unlock
|
||||||
|
inside.** This is two ergonomic guarantees in one command. Outside, it's
|
||||||
|
a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any
|
||||||
|
user who installed `relicario` just for the generator. Inside, it
|
||||||
|
consults `settings.generator_defaults` so the user gets the policy they
|
||||||
|
configured. The branch is the `vault_dir().is_ok()` check at
|
||||||
|
`main.rs:1440`. Tests pin both behaviours.
|
||||||
|
|
||||||
|
- **TOTP edit pushes history under the synthetic key `core:totp_secret`,
|
||||||
|
not `core:totp` or anything else.** This is what `relicario history
|
||||||
|
<query> --field totp_secret` matches against. The naming convention
|
||||||
|
("type underscore field") is shared across all five history-tracked
|
||||||
|
fields (see Invariants). If you add a new history-tracked field, pick a
|
||||||
|
matching `<type>_<field>` form so the user-facing `--field` filter
|
||||||
|
stays predictable.
|
||||||
|
|
||||||
|
- **`detach` refuses a Document item's primary attachment.**
|
||||||
|
(`main.rs:1392-1400`) Document items model "this item *is* a file"; the
|
||||||
|
primary blob isn't optional. The error directs the user to `purge`
|
||||||
|
instead. Non-primary attachments on a Document (e.g., a scanned
|
||||||
|
contract with an addendum) detach normally.
|
||||||
|
|
||||||
|
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
|
||||||
|
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
|
||||||
|
carried 217-line `match` arms. The split-out functions are easier to
|
||||||
|
read, easier to test individually (the existing integration tests still
|
||||||
|
drive them through the same paths), and easier to grow when a new
|
||||||
|
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
||||||
|
|
||||||
|
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
||||||
|
reasons. (1) Dep tree: pulling in libgit2 doubles compile time and
|
||||||
|
adds a C dependency. (2) Override surface: users can put any
|
||||||
|
`~/.gitconfig` they want and it Just Works (subject to the hardening
|
||||||
|
flags). (3) Recovery: when something is wrong with a vault, the user
|
||||||
|
can poke around with `git log`, `git show`, `git fsck` directly; the
|
||||||
|
CLI's git interactions are not opaque.
|
||||||
|
|
||||||
|
- **The hardened-`git` injection set is load-bearing.** `git_command`
|
||||||
|
prepends three `-c` flags before the user-supplied args
|
||||||
|
(`helpers.rs:48-52`):
|
||||||
|
- `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned
|
||||||
|
vault could otherwise run arbitrary code on every commit. Master key
|
||||||
|
is in memory at the time of commit; this matters.
|
||||||
|
- `commit.gpgsign=false` — if the user has global GPG signing on, the
|
||||||
|
GPG agent prompt would block on `git commit` and hold the master
|
||||||
|
key alive in memory until the user types the passphrase. Disable it
|
||||||
|
for relicario commits.
|
||||||
|
- `core.editor=true` — `true(1)` exits 0 with no output. If `git`
|
||||||
|
decides to drop into `$EDITOR` (rebase conflict markers, missing
|
||||||
|
`-m`), this neutralises it without crashing the rebase. We pass
|
||||||
|
`-m <msg>` ourselves; this flag is the seatbelt.
|
||||||
|
All three were added together in audit H4. A user can still run
|
||||||
|
`git` themselves with their own config to inspect or repair the
|
||||||
|
vault — the hardening only applies to relicario's invocations.
|
||||||
|
|
||||||
|
- **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4
|
||||||
|
}`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE`
|
||||||
|
bypasses the prompt but does not lower the KDF cost. This is a
|
||||||
|
trade-off: integration tests pay the full Argon2id cost (~half a
|
||||||
|
second per init on a modern machine), but the same code path runs in
|
||||||
|
production. Don't lower the params here — the core's test-only fast
|
||||||
|
params are for `relicario-core` unit tests.
|
||||||
|
|
||||||
|
- **`params.json` has a nested `kdf` object, not a flat one.**
|
||||||
|
`read_params` (`session.rs:110-121`) deserializes via a private
|
||||||
|
`ParamsFile { kdf: KdfParams }` struct. The nesting exists so
|
||||||
|
`format_version`, `aead`, and `salt_path` can co-exist in the same
|
||||||
|
file for forward-compat. An earlier version of `read_params` tried
|
||||||
|
to deserialize the whole file as `KdfParams` and failed silently —
|
||||||
|
that bug was fixed in commit `b263c27`.
|
||||||
|
|
||||||
|
- **`commit_paths` is the convention but not always the call site.**
|
||||||
|
`cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command`
|
||||||
|
directly because their add/commit pattern doesn't quite fit
|
||||||
|
`commit_paths(vault, msg, &[paths...])`. They still use the
|
||||||
|
hardened wrapper, just at one level lower. If you find yourself
|
||||||
|
writing a new command with the same shape, prefer `commit_paths`;
|
||||||
|
reach for `git_command` directly only when you need the slightly
|
||||||
|
different control flow these three have.
|
||||||
|
|
||||||
|
- **Initial commit at `cmd_init` does not use `commit_paths`.**
|
||||||
|
Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't
|
||||||
|
construct one — it uses the master key inline before the vault
|
||||||
|
exists. The init commit goes through `git_command` directly
|
||||||
|
(`main.rs:403-412`). This is the only production code site outside
|
||||||
|
`commit_paths` that does so.
|
||||||
|
|
||||||
|
- **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a
|
||||||
|
session — every command re-derives the master key. The command
|
||||||
|
exists only for UX parity with the extension, where `lock` actually
|
||||||
|
evicts a cached session. Printed message: `no cached session to
|
||||||
|
lock`.
|
||||||
|
|
||||||
|
- **`resolve_query` accepts an item id or a case-insensitive title
|
||||||
|
substring** (`main.rs:855-871`). Exact id-match wins; otherwise it
|
||||||
|
defers to `Manifest::search`. Multi-hit substring matches are
|
||||||
|
rejected with an "ambiguous" error listing the matched titles. This
|
||||||
|
is why every `cmd_*` that takes a `query: String` (get, edit,
|
||||||
|
history, rm, restore, purge, attach, attachments, extract, detach)
|
||||||
|
works the same way.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-cli"
|
name = "relicario-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "CLI for relicario password manager"
|
description = "CLI for relicario password manager"
|
||||||
|
|
||||||
@@ -24,10 +24,14 @@ serde_json = "1"
|
|||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
url = "2"
|
url = "2"
|
||||||
data-encoding = "2"
|
data-encoding = "2"
|
||||||
|
tar = { version = "0.4", default-features = false }
|
||||||
|
clap_complete = "4"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
|
rqrr = "0.7"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
qrcode = "0.14"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -63,6 +63,86 @@ pub fn iso8601(unix_seconds: i64) -> String {
|
|||||||
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a duration (in seconds) as a coarse human-readable string:
|
||||||
|
/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago".
|
||||||
|
pub fn humanize_age(seconds: i64) -> String {
|
||||||
|
if seconds < 60 { return "just now".to_string(); }
|
||||||
|
if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); }
|
||||||
|
if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); }
|
||||||
|
if seconds < 86_400 * 30 {
|
||||||
|
let d = seconds / 86_400;
|
||||||
|
return format!("{d} day{} ago", plural(d));
|
||||||
|
}
|
||||||
|
if seconds < 86_400 * 365 {
|
||||||
|
let m = seconds / (86_400 * 30);
|
||||||
|
return format!("{m} month{} ago", plural(m));
|
||||||
|
}
|
||||||
|
let y = seconds / (86_400 * 365);
|
||||||
|
format!("{y} year{} ago", plural(y))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
||||||
|
|
||||||
|
/// Path to the plaintext `groups.cache` file used by shell completion to
|
||||||
|
/// enumerate `--group <TAB>` candidates without unlocking the vault.
|
||||||
|
///
|
||||||
|
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||||
|
/// vault directory. This is intentional — the file feeds shell completion,
|
||||||
|
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
||||||
|
/// to suppress the write.
|
||||||
|
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||||
|
vault_dir.join(".relicario").join("groups.cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||||
|
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
||||||
|
pub fn write_groups_cache(
|
||||||
|
vault_dir: &Path,
|
||||||
|
groups: &std::collections::BTreeSet<String>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = groups_cache_path(vault_dir);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut body = String::new();
|
||||||
|
for g in groups {
|
||||||
|
body.push_str(g);
|
||||||
|
body.push('\n');
|
||||||
|
}
|
||||||
|
std::fs::write(path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
||||||
|
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
||||||
|
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
||||||
|
let img = image::open(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read image: {e}"))?
|
||||||
|
.to_luma8();
|
||||||
|
let mut prepared = rqrr::PreparedImage::prepare(img);
|
||||||
|
let grids = prepared.detect_grids();
|
||||||
|
let grid = grids
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?;
|
||||||
|
let (_meta, content) = grid
|
||||||
|
.decode()
|
||||||
|
.map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?;
|
||||||
|
if !content.starts_with("otpauth://") {
|
||||||
|
return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)"));
|
||||||
|
}
|
||||||
|
let parsed =
|
||||||
|
url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?;
|
||||||
|
let secret = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k == "secret")
|
||||||
|
.map(|(_, v)| v.to_string())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?;
|
||||||
|
Ok(secret)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -98,4 +178,21 @@ mod tests {
|
|||||||
// 2026-04-19T00:00:00Z = 1776556800
|
// 2026-04-19T00:00:00Z = 1776556800
|
||||||
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_age_buckets() {
|
||||||
|
assert_eq!(humanize_age(0), "just now");
|
||||||
|
assert_eq!(humanize_age(59), "just now");
|
||||||
|
assert_eq!(humanize_age(60), "1 minute ago");
|
||||||
|
assert_eq!(humanize_age(120), "2 minutes ago");
|
||||||
|
assert_eq!(humanize_age(3_599), "59 minutes ago");
|
||||||
|
assert_eq!(humanize_age(3_600), "1 hour ago");
|
||||||
|
assert_eq!(humanize_age(7_200), "2 hours ago");
|
||||||
|
assert_eq!(humanize_age(86_400), "1 day ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 2), "2 days ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 30), "1 month ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 60), "2 months ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 365), "1 year ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 365 * 3), "3 years ago");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,67 @@ fn attach_list_extract_round_trip() {
|
|||||||
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
|
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detach_removes_attachment_and_blob() {
|
||||||
|
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());
|
||||||
|
|
||||||
|
let list = v.run(&["attachments", "thing"]);
|
||||||
|
let stdout = String::from_utf8(list.stdout).unwrap();
|
||||||
|
let aid = stdout.lines()
|
||||||
|
.find(|l| l.contains("payload.txt"))
|
||||||
|
.and_then(|l| l.split_whitespace().next())
|
||||||
|
.expect("aid token")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Detach removes the attachment from the item AND deletes the blob.
|
||||||
|
let out = v.run(&["detach", "thing", &aid]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"detach failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Item no longer lists the attachment.
|
||||||
|
let list2 = v.run(&["attachments", "thing"]);
|
||||||
|
let stdout2 = String::from_utf8(list2.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout2.contains("payload.txt"),
|
||||||
|
"attachment still listed after detach: {stdout2}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypted blob file is gone.
|
||||||
|
let blob_path = v.path()
|
||||||
|
.join("attachments")
|
||||||
|
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
|
||||||
|
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||||
|
.unwrap().next().unwrap().unwrap().path();
|
||||||
|
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||||
|
assert!(!blob.exists(), "blob still on disk: {}", blob.display());
|
||||||
|
let _ = blob_path; // keep the variable to avoid an unused warning
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detach_refuses_unknown_aid() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "thing",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
let out = v.run(&["detach", "thing", "deadbeef"]);
|
||||||
|
assert!(!out.status.success(), "expected failure: {:?}", out);
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&out.stderr).to_lowercase().contains("no attachment"),
|
||||||
|
"expected 'no attachment' error in stderr"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_rejects_over_cap() {
|
fn attach_rejects_over_cap() {
|
||||||
let v = TestVault::init();
|
let v = TestVault::init();
|
||||||
|
|||||||
142
crates/relicario-cli/tests/backup.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
mod common;
|
||||||
|
use common::TestVault;
|
||||||
|
use std::process::Command;
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
|
||||||
|
const BACKUP_PASS: &str = "strong-backup-pass-test-2026";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_then_restore_round_trip() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
v.run(&["add", "login", "--title", "GitHub", "--username", "alice", "--password", "p"]);
|
||||||
|
v.run(&["add", "login", "--title", "Email", "--username", "bob", "--password", "q"]);
|
||||||
|
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
let out = v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap()],
|
||||||
|
BACKUP_PASS,
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "export failed: {:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
assert!(backup_path.exists());
|
||||||
|
assert!(v.path().join(".relicario/last_backup").exists());
|
||||||
|
|
||||||
|
// Restore into a fresh dir.
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "restore failed: {:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Vault should be unlockable in the restore dir using the same passphrase + image.
|
||||||
|
// Since the original vault didn't include the image, we copy it in manually
|
||||||
|
// (the standard restore-without-image flow expects the user to keep their
|
||||||
|
// reference image separately).
|
||||||
|
std::fs::copy(&v.reference_image, restore_dir.path().join("reference.jpg")).unwrap();
|
||||||
|
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_IMAGE", restore_dir.path().join("reference.jpg"))
|
||||||
|
.args(["list"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("GitHub"));
|
||||||
|
assert!(stdout.contains("Email"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_refuses_non_empty_target() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS);
|
||||||
|
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(v.path()) // already has a .relicario/
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let err = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(err.contains("already contains a Relicario vault"), "stderr: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_with_include_image_round_trips_the_image() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap(), "--include-image"],
|
||||||
|
BACKUP_PASS,
|
||||||
|
);
|
||||||
|
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
assert!(restore_dir.path().join("reference.jpg").exists(),
|
||||||
|
"image should be restored when --include-image was used");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_with_no_history_skips_git_dir() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap(), "--no-history"],
|
||||||
|
BACKUP_PASS,
|
||||||
|
);
|
||||||
|
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// .git/ should exist but contain only the "restore from backup ..." commit.
|
||||||
|
assert!(restore_dir.path().join(".git").is_dir());
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.args(["log", "--oneline"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let log = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(log.lines().count(), 1, "expected one commit, got: {log}");
|
||||||
|
assert!(log.contains("restore from backup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_backup_passphrase_fails() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS);
|
||||||
|
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", "definitely-wrong")
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let err = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(err.contains("wrong backup passphrase"), "stderr: {err}");
|
||||||
|
}
|
||||||
@@ -78,6 +78,19 @@ impl TestVault {
|
|||||||
cmd.output().unwrap()
|
cmd.output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &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)
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", backup_pass)
|
||||||
|
.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 {
|
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
cmd.current_dir(self.dir.path())
|
cmd.current_dir(self.dir.path())
|
||||||
|
|||||||
@@ -57,3 +57,135 @@ fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::pro
|
|||||||
}
|
}
|
||||||
child.wait_with_output().unwrap()
|
child.wait_with_output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_lists_per_field_entries() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "bank",
|
||||||
|
"--username", "u", "--password", "first-pw"]);
|
||||||
|
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||||
|
assert!(out.status.success(), "edit failed: {:?}", out);
|
||||||
|
|
||||||
|
// `history <query>` should list the captured field and a count.
|
||||||
|
let out = v.run(&["history", "bank"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"history failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("login_password"),
|
||||||
|
"expected login_password key, got: {stdout}"
|
||||||
|
);
|
||||||
|
// Default (no --show) hides values.
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("first-pw"),
|
||||||
|
"values should be masked without --show: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("****"),
|
||||||
|
"expected masked value indicator: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_show_reveals_prior_values() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "bank",
|
||||||
|
"--username", "u", "--password", "first-pw"]);
|
||||||
|
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||||
|
assert!(out.status.success());
|
||||||
|
|
||||||
|
let out = v.run(&["history", "bank", "--show"]);
|
||||||
|
assert!(out.status.success(), "history --show failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("first-pw"),
|
||||||
|
"expected old value 'first-pw' in --show output: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_reports_empty_when_nothing_changed() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "untouched",
|
||||||
|
"--username", "u", "--password", "pw"]);
|
||||||
|
|
||||||
|
let out = v.run(&["history", "untouched"]);
|
||||||
|
assert!(out.status.success(), "history failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.to_lowercase().contains("no history"),
|
||||||
|
"expected 'no history' message, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_totp_rotates_secret_and_captures_history() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&[
|
||||||
|
"add", "totp",
|
||||||
|
"--title", "github",
|
||||||
|
"--issuer", "github.com",
|
||||||
|
"--label", "alice",
|
||||||
|
"--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Edit: change issuer, label, then rotate the secret to a new base32 value.
|
||||||
|
let out = run_edit_totp(&v, "github", "github-new.com", "alice@new", "NB2W45DFOIZA");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"edit failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the issuer and label changes persisted by reading the item back.
|
||||||
|
let out = v.run(&["get", "github"]);
|
||||||
|
assert!(out.status.success(), "get failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("github-new.com"),
|
||||||
|
"expected new issuer in get output, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alice@new"),
|
||||||
|
"expected new label in get output, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the interactive `edit` flow for a TOTP item with secret rotation.
|
||||||
|
/// Stdin order: Title, Group, Tags (all blank to keep), Issuer, Label,
|
||||||
|
/// then "y" to "Change TOTP secret?" The new secret comes from
|
||||||
|
/// RELICARIO_TEST_ITEM_SECRET.
|
||||||
|
fn run_edit_totp(
|
||||||
|
v: &TestVault,
|
||||||
|
query: &str,
|
||||||
|
new_issuer: &str,
|
||||||
|
new_label: &str,
|
||||||
|
new_secret_b32: &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_secret_b32)
|
||||||
|
.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();
|
||||||
|
for line in ["", "", "", new_issuer, new_label, "y"] {
|
||||||
|
writeln!(stdin, "{line}").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
|||||||
17
crates/relicario-cli/tests/fixtures/lastpass-sample.csv
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
url,username,password,totp,extra,name,grouping,fav
|
||||||
|
https://github.com/login,alice@example.com,hunter2-strong,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,One-time URL: https://github.com/recover,GitHub,Work,1
|
||||||
|
https://gmail.com,bob@example.com,p@ssw0rd-2026,,,Gmail,Personal,
|
||||||
|
https://news.ycombinator.com,charlie,hn-secret,,,Hacker News,,
|
||||||
|
https://aws.console,d-user,aws-pass,!!!not-base32!!!,,AWS,Work,
|
||||||
|
http://sn,,,,Wifi password: hunter2hunter2,Home Wifi,Personal,
|
||||||
|
http://sn,,,,"NoteType:Credit Card
|
||||||
|
Number:4111111111111111
|
||||||
|
Expiry:01/2030
|
||||||
|
CVV:123",Visa Card,Personal,
|
||||||
|
https://日本語.example,user,pass,,,日本語サイト,,
|
||||||
|
not-a-real-url,user,pass,,,Bad URL,,
|
||||||
|
,,,,,,,
|
||||||
|
https://x,user,,,,No Password,,
|
||||||
|
https://example.com,user,p,,"multi
|
||||||
|
line
|
||||||
|
notes",Multiline,,
|
||||||
|
127
crates/relicario-cli/tests/import_lastpass.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
mod common;
|
||||||
|
use common::TestVault;
|
||||||
|
|
||||||
|
const FIXTURE: &str = "tests/fixtures/lastpass-sample.csv";
|
||||||
|
|
||||||
|
fn fixture_path() -> std::path::PathBuf {
|
||||||
|
// Manifest dir = crates/relicario-cli; the fixture is relative to it.
|
||||||
|
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_logins_secure_notes_and_warns_on_skipped() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"import failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
// 9 items expected (see fixture comment).
|
||||||
|
assert!(stderr.contains("Imported 9"), "stderr: {stderr}");
|
||||||
|
assert!(stderr.contains("skipped 2"), "stderr: {stderr}");
|
||||||
|
|
||||||
|
// Each warning surfaces.
|
||||||
|
assert!(stderr.contains("invalid base32 TOTP"), "TOTP warning missing");
|
||||||
|
assert!(stderr.contains("invalid URL"), "URL warning missing");
|
||||||
|
assert!(stderr.contains("missing `name`"), "name-missing warning missing");
|
||||||
|
assert!(stderr.contains("missing `password`"), "password-missing warning missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_after_import_shows_imported_titles() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("GitHub"));
|
||||||
|
assert!(stdout.contains("Gmail"));
|
||||||
|
assert!(stdout.contains("Home Wifi"));
|
||||||
|
assert!(stdout.contains("Visa Card"));
|
||||||
|
assert!(stdout.contains("日本語サイト"));
|
||||||
|
// Skipped rows must NOT appear.
|
||||||
|
assert!(!stdout.contains("No Password"),
|
||||||
|
"row with no password should have been skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_creates_a_single_git_commit() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Count commits before.
|
||||||
|
let before = std::process::Command::new("git")
|
||||||
|
.arg("-C").arg(v.path())
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.output().unwrap();
|
||||||
|
let before_n: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
|
||||||
|
let after = std::process::Command::new("git")
|
||||||
|
.arg("-C").arg(v.path())
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.output().unwrap();
|
||||||
|
let after_n: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(after_n, before_n + 1, "expected exactly one new commit");
|
||||||
|
|
||||||
|
// Commit message includes the count + "LastPass".
|
||||||
|
let log = std::process::Command::new("git")
|
||||||
|
.arg("-C").arg(v.path())
|
||||||
|
.args(["log", "-1", "--pretty=%s"])
|
||||||
|
.output().unwrap();
|
||||||
|
let subject = String::from_utf8(log.stdout).unwrap();
|
||||||
|
assert!(subject.contains("9 items"));
|
||||||
|
assert!(subject.contains("LastPass"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_with_zero_items_exits_nonzero() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Header-only CSV with one bad row → 0 items.
|
||||||
|
let bad_csv = v.path().join("empty.csv");
|
||||||
|
std::fs::write(
|
||||||
|
&bad_csv,
|
||||||
|
"url,username,password,totp,extra,name,grouping,fav\n,,,,,,,\n",
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success(), "expected non-zero exit on zero items");
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(stderr.contains("imported 0 items"), "stderr: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_rejects_unrecognized_header() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let bad_csv = v.path().join("wrong.csv");
|
||||||
|
std::fs::write(&bad_csv, "name,url,user,pass\nA,https://x,u,p\n").unwrap();
|
||||||
|
|
||||||
|
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
stderr.contains("LastPass") || stderr.contains("expected"),
|
||||||
|
"stderr: {stderr}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imported_items_keep_unique_ids_across_runs() {
|
||||||
|
// Decision D12: two imports of the same CSV must not collide.
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
// Each title imported twice — count occurrences of "GitHub" must be 2.
|
||||||
|
let github_count = stdout.matches("GitHub").count();
|
||||||
|
assert_eq!(github_count, 2, "stdout: {stdout}");
|
||||||
|
}
|
||||||
@@ -21,3 +21,139 @@ fn settings_rejects_conflicting_retention_flags() {
|
|||||||
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
||||||
assert!(!out.status.success());
|
assert!(!out.status.success());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_uses_vault_default_length() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Default vault settings: GeneratorRequest::Random { length: 20, ... }.
|
||||||
|
let out = v.run(&["generate"]);
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
20,
|
||||||
|
"expected 20 chars at default, got {pw:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the vault default length to 32.
|
||||||
|
let out = v.run(&["settings", "generator-defaults", "--length", "32"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"set generator-defaults failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `generate` (no flags) should now produce 32 chars.
|
||||||
|
let out = v.run(&["generate"]);
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
32,
|
||||||
|
"expected 32 chars after update, got {pw:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit flag overrides the vault default.
|
||||||
|
let out = v.run(&["generate", "--length", "8"]);
|
||||||
|
assert!(out.status.success());
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
8,
|
||||||
|
"explicit flag should override vault default, got {pw:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_reports_item_attachment_and_device_counts() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "active",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
v.run(&["add", "login", "--title", "to-trash",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
v.run(&["rm", "to-trash"]);
|
||||||
|
|
||||||
|
let payload = v.path().join("payload.txt");
|
||||||
|
std::fs::write(&payload, b"hello-world").unwrap();
|
||||||
|
v.run(&["attach", "active", payload.to_str().unwrap()]);
|
||||||
|
|
||||||
|
let out = v.run(&["status"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"status failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let lower = stdout.to_lowercase();
|
||||||
|
|
||||||
|
// 1 active + 1 trashed = 2 items total.
|
||||||
|
assert!(lower.contains("items"), "missing items section: {stdout}");
|
||||||
|
assert!(stdout.contains('2') || stdout.contains("2 ")
|
||||||
|
|| lower.contains("active: 1") || lower.contains("1 active"),
|
||||||
|
"expected item counts in output: {stdout}");
|
||||||
|
assert!(lower.contains("trash"), "missing trash count: {stdout}");
|
||||||
|
|
||||||
|
// 1 attachment, 11 bytes.
|
||||||
|
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
|
||||||
|
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
|
||||||
|
|
||||||
|
// 0 devices in default test vault (init does not register one).
|
||||||
|
assert!(lower.contains("device"), "missing devices section: {stdout}");
|
||||||
|
|
||||||
|
// Last-commit line.
|
||||||
|
assert!(
|
||||||
|
lower.contains("last commit") || lower.contains("commit"),
|
||||||
|
"missing last-commit info: {stdout}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_shows_last_backup_line() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let out = v.run(&["status"]);
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("Last export:"), "missing last export line: {stdout}");
|
||||||
|
assert!(stdout.contains("never"), "fresh vault should report 'never': {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_shows_recent_backup_after_export() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("v.relbak");
|
||||||
|
v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap()],
|
||||||
|
"test-backup-pass-2026",
|
||||||
|
);
|
||||||
|
let out = v.run(&["status"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("Last export:"), "{stdout}");
|
||||||
|
assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_works_outside_vault() {
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.args(["generate", "--length", "12"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"no-vault generate failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(pw.trim().chars().count(), 12);
|
||||||
|
}
|
||||||
|
|||||||
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use assert_cmd::Command;
|
||||||
|
use predicates::prelude::PredicateBooleanExt;
|
||||||
|
use predicates::str::contains;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_bash_emits_script() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["completions", "bash"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("_relicario"))
|
||||||
|
.stdout(contains("complete -F"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_zsh_emits_script() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["completions", "zsh"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("#compdef relicario"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_fish_emits_script() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["completions", "fish"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("complete -c relicario"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_command_refreshes_groups_cache() {
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", "T",
|
||||||
|
"--username", "u",
|
||||||
|
"--group", "work",
|
||||||
|
"--password", "hunter2",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add failed: {:?}", out);
|
||||||
|
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
assert!(out.status.success(), "list failed: {:?}", out);
|
||||||
|
|
||||||
|
let cache_path = v.path().join(".relicario/groups.cache");
|
||||||
|
let cache = std::fs::read_to_string(&cache_path)
|
||||||
|
.unwrap_or_else(|e| panic!("groups.cache not found at {}: {e}", cache_path.display()));
|
||||||
|
assert!(
|
||||||
|
cache.lines().any(|l| l == "work"),
|
||||||
|
"expected 'work' in groups.cache, got: {cache:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_groups_cache_env_var_suppresses_write() {
|
||||||
|
use std::process::{Command as StdCommand, Stdio};
|
||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
// Add with the env var set so no cache is created by add either.
|
||||||
|
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||||
|
.current_dir(v.path())
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||||
|
.args([
|
||||||
|
"add", "login",
|
||||||
|
"--title", "T2",
|
||||||
|
"--username", "u",
|
||||||
|
"--group", "personal",
|
||||||
|
"--password", "hunter2",
|
||||||
|
])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "add failed: {:?}", out);
|
||||||
|
|
||||||
|
// Run list with RELICARIO_NO_GROUPS_CACHE=1 — cache must NOT be written.
|
||||||
|
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||||
|
.current_dir(v.path())
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||||
|
.args(["list"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "list failed: {:?}", out);
|
||||||
|
|
||||||
|
let cache_path = v.path().join(".relicario/groups.cache");
|
||||||
|
assert!(
|
||||||
|
!cache_path.exists(),
|
||||||
|
"groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_strong_passphrase_prints_score_and_guesses() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("score:"))
|
||||||
|
.stdout(contains("guesses:"))
|
||||||
|
.stdout(contains("strong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_weak_passphrase_exits_zero_with_weak_label() {
|
||||||
|
// `rate` is informational — does NOT exit nonzero on weak input.
|
||||||
|
// The hard gate lives at `init` (Plan 2B Task 10).
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["rate", "password"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("very weak").or(contains("weak")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_reads_from_stdin_when_arg_is_dash() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["rate", "-"])
|
||||||
|
.write_stdin("correcthorsebatterystaple\n")
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("score:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_test_qr(uri: &str, dest: &std::path::Path) {
|
||||||
|
use image::{ImageBuffer, Luma};
|
||||||
|
let code = qrcode::QrCode::new(uri).expect("QR encode failed");
|
||||||
|
let img: ImageBuffer<Luma<u8>, Vec<u8>> = code
|
||||||
|
.render::<Luma<u8>>()
|
||||||
|
.module_dimensions(8, 8)
|
||||||
|
.build();
|
||||||
|
img.save(dest).expect("save QR PNG");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_login_totp_qr_decodes_otpauth_uri() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let qr_path = tmp.path().join("test.png");
|
||||||
|
make_test_qr(
|
||||||
|
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||||
|
&qr_path,
|
||||||
|
);
|
||||||
|
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", "TotpTest",
|
||||||
|
"--password", "hunter2",
|
||||||
|
"--totp-qr", qr_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let out = v.run(&["get", "TotpTest", "--show"]);
|
||||||
|
assert!(out.status.success(), "get failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
// BASE32.encode(BASE32.decode("JBSWY3DPEHPK3PXP")) should round-trip.
|
||||||
|
// The secret bytes from JBSWY3DPEHPK3PXP decode to specific bytes,
|
||||||
|
// then re-encode to JBSWY3DPEHPK3PXP====; we check for the core chars.
|
||||||
|
assert!(
|
||||||
|
stdout.contains("JBSWY3DPEHPK3PXP"),
|
||||||
|
"expected TOTP secret in get output, got:\n{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_login_totp_qr_errors_on_non_otpauth_qr() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let qr_path = tmp.path().join("nottotp.png");
|
||||||
|
make_test_qr("https://example.com", &qr_path);
|
||||||
|
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", "BadQR",
|
||||||
|
"--password", "hunter2",
|
||||||
|
"--totp-qr", qr_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"expected nonzero exit for non-otpauth QR, but command succeeded"
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("not a TOTP URI"),
|
||||||
|
"expected 'not a TOTP URI' in stderr, got:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
514
crates/relicario-core/ARCHITECTURE.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
# Architecture: relicario-core
|
||||||
|
|
||||||
|
## What this crate is for
|
||||||
|
|
||||||
|
`relicario-core` is the platform-agnostic cryptographic and data-model heart of the
|
||||||
|
relicario password manager. It is strictly **bytes-in / bytes-out**: every public
|
||||||
|
function takes byte slices or owned typed structs and returns byte vectors or typed
|
||||||
|
structs. The crate performs no filesystem I/O, no network I/O, no git operations,
|
||||||
|
and no time-of-day reads beyond `chrono::Utc::now()` for timestamping items
|
||||||
|
(`time.rs:6`). This boundary is what lets the same compiled artifact serve the
|
||||||
|
native CLI (`relicario-cli`), a `wasm32-unknown-unknown` build embedded in the
|
||||||
|
Chrome MV3 / Firefox WebExtension popup (`relicario-wasm`), and (eventually) ARM
|
||||||
|
mobile builds — without conditional compilation. Anything that touches a
|
||||||
|
`Path`, opens a socket, or shells out belongs in `relicario-cli` or the
|
||||||
|
extension layer, never here. The historical rationale is in
|
||||||
|
`docs/superpowers/specs/2026-04-11-relicario-design.md` (sections "Crypto
|
||||||
|
Pipeline" and "Crate Layout").
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
- **`lib.rs`** — Public API surface. Re-exports the symbols that callers actually
|
||||||
|
need (`encrypt_item`, `derive_master_key`, `Item`, `ItemCore`, etc.). The
|
||||||
|
module list here is the contract; everything else is internal.
|
||||||
|
- **`error.rs`** — `RelicarioError` (a `thiserror`-derived enum) plus the crate
|
||||||
|
alias `Result<T> = std::result::Result<T, RelicarioError>`. One error type
|
||||||
|
for the whole crate so FFI / WASM bindings and CLI handlers each have a single
|
||||||
|
exhaustive `match` to maintain. `Decrypt` is intentionally opaque (no inner
|
||||||
|
detail string) — see "Cross-cutting concerns".
|
||||||
|
- **`crypto.rs`** — KDF (`derive_master_key`, Argon2id with NFC-normalized,
|
||||||
|
length-prefixed inputs) and AEAD (`encrypt`, `decrypt`, XChaCha20-Poly1305
|
||||||
|
with `VERSION_BYTE = 0x02`). Owns the on-disk ciphertext layout. The KDF
|
||||||
|
parameters (`KdfParams`) are an owned struct that callers persist however
|
||||||
|
they like (CLI puts them in `.relicario/params.json`); the crate has no
|
||||||
|
opinion about storage.
|
||||||
|
- **`ids.rs`** — `ItemId`, `FieldId` (random 64-bit hex from `OsRng`,
|
||||||
|
`ids.rs:26-32`, `ids.rs:38-49`) and content-addressed `AttachmentId`
|
||||||
|
(first 8 bytes of `SHA-256(plaintext)`, `ids.rs:51-57`). Three separate
|
||||||
|
newtypes rather than `String` so misuses can't compile.
|
||||||
|
- **`time.rs`** — `now_unix()` and `MonthYear` (the validated 1..=12 / 2000..=2099
|
||||||
|
card-expiry type). Trivially small; broken out only because every other module
|
||||||
|
needs `now_unix()` and `MonthYear` is used by both `item.rs` and
|
||||||
|
`item_types/card.rs`.
|
||||||
|
- **`item_types/mod.rs`** — `ItemType` enum (snake-case wire tag) and `ItemCore`
|
||||||
|
(internally tagged `#[serde(tag = "type")]` enum), with one variant per item
|
||||||
|
type. The "extension via match exhaustiveness" pattern is documented at
|
||||||
|
`item_types/mod.rs:1-7`: adding an item type is a `cargo check` walk through
|
||||||
|
every match arm. Re-exports each per-type core.
|
||||||
|
- **`item_types/login.rs`** — `LoginCore` (username, password as
|
||||||
|
`Zeroizing<String>`, optional `Url`, optional `TotpConfig`).
|
||||||
|
- **`item_types/secure_note.rs`** — `SecureNoteCore` (single `Zeroizing<String>`
|
||||||
|
body).
|
||||||
|
- **`item_types/identity.rs`** — `IdentityCore` (full name, address, phone,
|
||||||
|
email, DOB; all optional, none `Zeroizing` — they're personal data, not
|
||||||
|
secret material).
|
||||||
|
- **`item_types/card.rs`** — `CardCore` plus `CardKind` (Credit/Debit/Gift/
|
||||||
|
Loyalty/Other). `number`, `cvv`, `pin` are `Zeroizing`; `holder` is plain
|
||||||
|
`String`.
|
||||||
|
- **`item_types/key.rs`** — `KeyCore`: opaque `Zeroizing<String>` `key_material`
|
||||||
|
with optional label / public key / algorithm. Used for SSH keys, GPG keys,
|
||||||
|
arbitrary blobs.
|
||||||
|
- **`item_types/document.rs`** — `DocumentCore`: filename + mime + a single
|
||||||
|
`AttachmentId` pointing at the primary blob. The body lives in the
|
||||||
|
attachment store, not the item.
|
||||||
|
- **`item_types/totp.rs`** — `TotpCore`, `TotpConfig`, `TotpAlgorithm`
|
||||||
|
(Sha1/Sha256/Sha512), `TotpKind` (Totp / Hotp{counter} / Steam), and the
|
||||||
|
`compute_totp_code()` function. Includes the Steam Mobile Authenticator
|
||||||
|
5-character alphabet and its conversion (`item_types/totp.rs:103-110`).
|
||||||
|
The same `TotpConfig` is reused as a sub-struct of `LoginCore` (so a Login
|
||||||
|
item can carry its own TOTP without spawning a separate item).
|
||||||
|
- **`item.rs`** — The `Item` envelope. Holds the parallel `FieldKind` /
|
||||||
|
`FieldValue` enums (kept parallel so callers can ask the kind without
|
||||||
|
inspecting the value, `item.rs:1-6`), `Field`, `Section`, `FieldHistoryEntry`,
|
||||||
|
and the `Item` struct itself with its `set_field_value` / `soft_delete` /
|
||||||
|
`restore` / `prune_history` mutators. Custom-fields and field-history live
|
||||||
|
here, not in the per-type cores.
|
||||||
|
- **`attachment.rs`** — `AttachmentRef` (full record carried on `Item`),
|
||||||
|
`AttachmentSummary` (compact form carried in `Manifest`),
|
||||||
|
`EncryptedAttachment`, and the `encrypt_attachment` / `decrypt_attachment`
|
||||||
|
helpers. The size cap is enforced **before** any crypto work (`attachment.rs:69-74`).
|
||||||
|
- **`manifest.rs`** — The browse-without-decrypt index: `Manifest`,
|
||||||
|
`ManifestEntry`, `MANIFEST_SCHEMA_VERSION = 2`. `upsert(&item)` rebuilds the
|
||||||
|
entry from the item — there is no path for the manifest to drift from the
|
||||||
|
source-of-truth item file. Includes case-insensitive title/tag search
|
||||||
|
(`manifest.rs:59-68`) and Login icon-hint derivation (host of the URL,
|
||||||
|
`manifest.rs:93-99`).
|
||||||
|
- **`settings.rs`** — `VaultSettings` and its sub-types: `TrashRetention`,
|
||||||
|
`HistoryRetention`, `GeneratorRequest` (`Random` or `Bip39`),
|
||||||
|
`AttachmentCaps`, plus the `autofill_origin_acks` map for the extension's
|
||||||
|
TOFU prompt.
|
||||||
|
- **`generators.rs`** — Random-password and BIP-39 passphrase generation, both
|
||||||
|
driven by `GeneratorRequest` from `settings.rs`. zxcvbn-backed
|
||||||
|
`rate_passphrase` and the `validate_passphrase_strength` gate that rejects
|
||||||
|
any score < 3.
|
||||||
|
- **`vault.rs`** — Typed wrappers around `crypto::{encrypt, decrypt}`:
|
||||||
|
`encrypt_item`/`decrypt_item`, `encrypt_manifest`/`decrypt_manifest`,
|
||||||
|
`encrypt_settings`/`decrypt_settings`. Each does
|
||||||
|
`serde_json::to_vec → encrypt` (or the inverse). The plaintext `Vec<u8>` is
|
||||||
|
wrapped in `Zeroizing` between serde and the cipher
|
||||||
|
(`vault.rs:18-19`, `vault.rs:24-26`).
|
||||||
|
- **`imgsecret.rs`** — Self-contained DCT-based steganography for the second
|
||||||
|
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
||||||
|
Quantization Index Modulation, and crop-recovery extractor. No other module
|
||||||
|
imports it; it is consumed only via the public re-export from `lib.rs`.
|
||||||
|
|
||||||
|
## Invariants & contracts
|
||||||
|
|
||||||
|
- **No filesystem, no network, no git, no spawn.** Verified by inspecting
|
||||||
|
imports; the only I/O-shaped types in use are in-memory `Cursor<&[u8]>`
|
||||||
|
for image decoding (`imgsecret.rs:243`).
|
||||||
|
- **No `unsafe`.** Confirmed by `grep` over `src/`. The crate compiles to WASM
|
||||||
|
unmodified for that reason.
|
||||||
|
- **No `async`.** All operations are pure compute on byte slices. Async lives
|
||||||
|
in `relicario-cli` (process spawning) and in the extension's service worker
|
||||||
|
(message channels), not here.
|
||||||
|
- **`VERSION_BYTE = 0x02`** (`crypto.rs:59`). Every blob produced by
|
||||||
|
`encrypt()` starts with this byte; `decrypt()` rejects any other value with
|
||||||
|
`RelicarioError::UnsupportedFormatVersion { found, expected }`
|
||||||
|
(`crypto.rs:127-132`). v1 blobs (the pre-rewrite format) are explicitly
|
||||||
|
tested for rejection (`tests/format_v2.rs:28-42`).
|
||||||
|
- **AEAD blob layout** is fixed at `version(1) || nonce(24) || ciphertext+tag(≥16)`
|
||||||
|
(`crypto.rs:18-32`). Minimum valid blob length is 41 bytes
|
||||||
|
(`crypto.rs:118-124`).
|
||||||
|
- **Nonces are always fresh from `OsRng`** (`crypto.rs:87-89`). There is no
|
||||||
|
caller-supplied nonce path. With 192 bits of randomness, collision risk is
|
||||||
|
negligible across the lifetime of any vault.
|
||||||
|
- **`MANIFEST_SCHEMA_VERSION = 2`** (`manifest.rs:12`). v1 manifests (which
|
||||||
|
predate typed items) are not handled here and are rejected at the JSON-parse
|
||||||
|
step.
|
||||||
|
- **KDF input is length-prefixed.** `derive_master_key` builds the password
|
||||||
|
buffer as `u64_be(len(passphrase)) || passphrase || u64_be(32) || image_secret`
|
||||||
|
(`crypto.rs:229-236`). This eliminates the (`"abc"`, `0x44…`) vs (`"abcD"`,
|
||||||
|
`…`) collision, and is exercised in
|
||||||
|
`crypto.rs:352-368` and `tests/format_v2.rs:44-54`.
|
||||||
|
- **Passphrases are NFC-normalized before hashing.** Bytes that aren't valid
|
||||||
|
UTF-8 pass through unchanged (`crypto.rs:223-227`). This keeps "café"
|
||||||
|
(precomposed) and "café" (combining acute) from producing different keys
|
||||||
|
(`crypto.rs:370-385`).
|
||||||
|
- **Master key only ever lives in `Zeroizing<[u8; 32]>`.** Returned that way
|
||||||
|
by `derive_master_key` (`crypto.rs:212`) and accepted that way by
|
||||||
|
`encrypt_item` / `encrypt_attachment` / friends. No public function in
|
||||||
|
`vault.rs` or `attachment.rs` accepts a raw `[u8; 32]`.
|
||||||
|
- **Plaintext is wrapped in `Zeroizing` between serde and the cipher.** See
|
||||||
|
`vault.rs:18-19`, `vault.rs:24-26`, `vault.rs:31-32`, `vault.rs:37-38`,
|
||||||
|
`vault.rs:44-45`, `vault.rs:50-51`. The serde JSON intermediate buffer is the
|
||||||
|
most exposed point, so it is wiped on drop.
|
||||||
|
- **`AttachmentId` is content-addressed** to the first 8 bytes (= 16 hex chars)
|
||||||
|
of `SHA-256(plaintext)` (`ids.rs:51-57`). Identical plaintexts deduplicate
|
||||||
|
in git automatically — proven in `tests/attachments.rs:28-35`. The 64-bit
|
||||||
|
prefix is used (rather than the full digest) to keep filenames short; the
|
||||||
|
collision space is still adequate for the expected vault size.
|
||||||
|
- **`ItemId` and `FieldId` are 16 hex chars** = 64 bits of `OsRng` entropy
|
||||||
|
(`ids.rs:25-32`, `ids.rs:38-49`). The audit (M8) bumped them from the
|
||||||
|
original 8-char / 32-bit format.
|
||||||
|
- **Field kind/value discriminants must agree.** `Field::new` derives `kind`
|
||||||
|
from `value` (`item.rs:85-94`); `Field::validate` (called after deserialize)
|
||||||
|
rejects any mismatch (`item.rs:97-107`). `set_field_value` further refuses
|
||||||
|
to change a field's kind (`item.rs:184-189`).
|
||||||
|
- **Field-history capture is restricted to three kinds:** `Password`,
|
||||||
|
`Concealed`, `Totp` (`item.rs:68-71`). Any other kind's update silently
|
||||||
|
skips history. The TOTP secret is base32-encoded for the history entry
|
||||||
|
(`item.rs:245-249`) so a user reading their history sees a recognizable
|
||||||
|
string.
|
||||||
|
- **History captures the *previous* value, not the new one** (`item.rs:190-197`):
|
||||||
|
`set_field_value` serializes `field.value` *before* assigning the new value.
|
||||||
|
- **`hidden_by_default` is set automatically** when the field's kind is
|
||||||
|
`Password` or `Concealed` (`item.rs:92`). The extension and CLI both honor
|
||||||
|
this hint when rendering.
|
||||||
|
- **Attachment cap is checked before encryption** (`attachment.rs:69-74`).
|
||||||
|
An oversize blob fails with `RelicarioError::AttachmentTooLarge { size, max }`
|
||||||
|
without ever calling `encrypt`. The CLI/extension are expected to read the
|
||||||
|
cap from `VaultSettings::attachment_caps`.
|
||||||
|
- **`Item::soft_delete` does not erase data.** It sets `trashed_at` and bumps
|
||||||
|
`modified` (`item.rs:205-208`). Purging is the caller's responsibility,
|
||||||
|
driven by `TrashRetention::should_purge` (`settings.rs:38-44`).
|
||||||
|
- **`prune_history` is idempotent and explicit.** Items keep all history until
|
||||||
|
the caller invokes it with a `HistoryRetention` policy (`item.rs:219-237`).
|
||||||
|
Last-N drops oldest first; Days drops anything older than `now - days·86400`.
|
||||||
|
- **`item_type()` is the single source of truth** for the type tag stored on
|
||||||
|
`Item`. `Item::new` derives `r#type` from the supplied `ItemCore`
|
||||||
|
(`item.rs:159-164`). Manual construction can violate this — the JSON
|
||||||
|
round-trip does not re-validate beyond serde's tag matching.
|
||||||
|
- **Reserved serde key:** no `*Core` may have a JSON-serialized field named
|
||||||
|
`"type"` — that name is reserved for serde's discriminator on `ItemCore`
|
||||||
|
(`item_types/mod.rs:38-40`). Use `"kind"` instead (see `CardKind`,
|
||||||
|
`TotpKind`).
|
||||||
|
- **`MAX_DIMENSION = 10_000`** for imgsecret (`imgsecret.rs:71`). Enforced via
|
||||||
|
a header-only peek (`imgsecret.rs:127-176`) at the entry of both `embed` and
|
||||||
|
`extract` so an attacker-supplied 32000×32000 JPEG is rejected without
|
||||||
|
decoding pixels (audit M3).
|
||||||
|
- **`MIN_DIMENSION = 100`** plus a "must hold ≥5 redundant copies" floor
|
||||||
|
(`imgsecret.rs:66`, `imgsecret.rs:78`, `imgsecret.rs:682-689`). Smaller
|
||||||
|
carriers are rejected with `ImageTooSmall`.
|
||||||
|
- **Strength gate is `score >= 3`** (`generators.rs:124-130`). Vault-creation
|
||||||
|
callers must invoke `validate_passphrase_strength` themselves; the crate
|
||||||
|
does not internally call it inside `derive_master_key` (since that path is
|
||||||
|
also used to derive the key for *unlock*, not just create).
|
||||||
|
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
||||||
|
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
|
||||||
|
|
||||||
|
## Key flows
|
||||||
|
|
||||||
|
### Vault unlock — key derivation
|
||||||
|
|
||||||
|
1. Caller obtains `passphrase: &[u8]` (UTF-8) and `image_secret: &[u8; 32]`
|
||||||
|
(typically from `imgsecret::extract` over the user's reference JPEG).
|
||||||
|
2. Caller loads `salt: [u8; 32]` and `KdfParams` from out-of-band storage
|
||||||
|
(CLI: `.relicario/salt` and `.relicario/params.json`).
|
||||||
|
3. `derive_master_key(passphrase, &image_secret, &salt, ¶ms)` —
|
||||||
|
`crypto.rs:207-244`:
|
||||||
|
- NFC-normalize the passphrase if it parses as UTF-8 (`crypto.rs:223-227`).
|
||||||
|
- Build the length-prefixed password buffer in a `Zeroizing<Vec<u8>>`
|
||||||
|
(`crypto.rs:229-236`).
|
||||||
|
- Run `Argon2id` with `Algorithm::Argon2id`, `Version::V0x13`,
|
||||||
|
output length 32 (`crypto.rs:213-221`, `crypto.rs:238-241`).
|
||||||
|
4. Returns `Zeroizing<[u8; 32]>` — automatically wiped on drop.
|
||||||
|
|
||||||
|
A wrong passphrase or wrong image produces a *different* derived key. The crate
|
||||||
|
cannot tell them apart at this stage; the caller learns "wrong factor" only
|
||||||
|
when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
||||||
|
|
||||||
|
### Item write
|
||||||
|
|
||||||
|
1. Caller mutates an `Item` (e.g. `item.set_field_value(&fid, new_value)` —
|
||||||
|
`item.rs:181-203`). `set_field_value` captures previous value into
|
||||||
|
`field_history` if the kind is history-tracked, then bumps `modified`.
|
||||||
|
2. Caller calls `encrypt_item(&item, &master_key)` — `vault.rs:16-20`:
|
||||||
|
`serde_json::to_vec(item)` → wrap in `Zeroizing` → `crypto::encrypt`.
|
||||||
|
3. Caller calls `manifest.upsert(&item)` (`manifest.rs:45-48`) to refresh the
|
||||||
|
browse-index entry; then `encrypt_manifest(&manifest, &master_key)`
|
||||||
|
(`vault.rs:29-33`).
|
||||||
|
4. The two ciphertext blobs are returned to the caller, who writes them to disk
|
||||||
|
(or commits them, or sends them over a sync channel).
|
||||||
|
|
||||||
|
### Item read (browse-without-decrypt path)
|
||||||
|
|
||||||
|
1. Caller calls `decrypt_manifest(&manifest_blob, &master_key)`
|
||||||
|
(`vault.rs:35-40`). One AEAD decryption gets the entire searchable index.
|
||||||
|
2. `Manifest::search(query)` does a case-insensitive substring match over title
|
||||||
|
and tags (`manifest.rs:59-68`). `manifest.items.values()` gives every
|
||||||
|
`ManifestEntry` with `title`, `tags`, `favorite`, `group`, `icon_hint`,
|
||||||
|
`modified`, `trashed_at`, and `attachment_summaries` — enough to render a
|
||||||
|
list UI without touching any item file.
|
||||||
|
3. When the user picks an entry, the caller reads `entries/<id>.enc` and calls
|
||||||
|
`decrypt_item(&blob, &master_key)` (`vault.rs:22-27`) to get the full
|
||||||
|
`Item` including secret fields and `field_history`.
|
||||||
|
|
||||||
|
### Attachment encryption
|
||||||
|
|
||||||
|
1. Caller has `plaintext: &[u8]`, the `master_key`, and the active
|
||||||
|
`VaultSettings::attachment_caps.per_attachment_max_bytes`.
|
||||||
|
2. `encrypt_attachment(plaintext, &master_key, max_bytes)` —
|
||||||
|
`attachment.rs:64-78`:
|
||||||
|
- If `plaintext.len() > max_bytes`, return `AttachmentTooLarge` *immediately*
|
||||||
|
before any crypto.
|
||||||
|
- `AttachmentId::from_plaintext(plaintext)` (SHA-256, `ids.rs:51-57`).
|
||||||
|
- `crypto::encrypt(master_key, plaintext)`.
|
||||||
|
3. Returns `EncryptedAttachment { id, bytes }`. The caller persists `bytes` at
|
||||||
|
`attachments/<id>.enc` and adds an `AttachmentRef { id, filename, mime_type,
|
||||||
|
size, created }` (`attachment.rs:11-20`) to the owning `Item`. On
|
||||||
|
`Manifest::upsert`, an `AttachmentSummary` (no `created` field) is derived
|
||||||
|
automatically (`manifest.rs:87`).
|
||||||
|
|
||||||
|
### Field-history capture
|
||||||
|
|
||||||
|
1. Triggered exclusively by `Item::set_field_value` (`item.rs:181-203`). Direct
|
||||||
|
mutation of `field.value` bypasses history — the type system does not
|
||||||
|
prevent this.
|
||||||
|
2. The check `field.value.is_history_tracked()` runs *on the existing value*
|
||||||
|
(`item.rs:190`), so adding the *first* password value to a previously-empty
|
||||||
|
field does not create a history entry; updating an already-set password
|
||||||
|
does.
|
||||||
|
3. The previous value is serialized via `serialize_history_value`
|
||||||
|
(`item.rs:241-253`):
|
||||||
|
- `Password(p)` and `Concealed(c)` clone the inner string into a fresh
|
||||||
|
`Zeroizing<String>`.
|
||||||
|
- `Totp(cfg)` base32-encodes the raw secret bytes
|
||||||
|
(`item.rs:245-249`, `item.rs:256-275`).
|
||||||
|
- Any other kind would error (`item.rs:250`), but is unreachable because
|
||||||
|
`is_history_tracked` already gated the call.
|
||||||
|
4. Pruning is *not* automatic. Callers (CLI commit hook, extension save handler)
|
||||||
|
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
||||||
|
when they want to enforce the policy.
|
||||||
|
|
||||||
|
### imgsecret embed
|
||||||
|
|
||||||
|
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
||||||
|
`imgsecret::embed(carrier_jpeg, &secret)` (`imgsecret.rs:666-726`).
|
||||||
|
2. `enforce_dimension_cap` walks JPEG markers (`imgsecret.rs:127-161`) to read
|
||||||
|
the SOF dimensions; rejects > 10_000 × 10_000 before any pixel decode.
|
||||||
|
3. `extract_y_channel` decodes via `image::ImageReader` and converts each pixel
|
||||||
|
to BT.601 luminance (`imgsecret.rs:242-265`).
|
||||||
|
4. `central_region` picks the inner 70% of the image as the embed region; the
|
||||||
|
15% margin per side is the "crumple zone" for crops
|
||||||
|
(`imgsecret.rs:268-293`).
|
||||||
|
5. `compute_embed_positions` / `select_embed_blocks` lay out
|
||||||
|
`num_copies × BLOCKS_PER_COPY` 8×8 blocks evenly across the region, with
|
||||||
|
`num_copies` = `min(50, total_blocks / 22)` (`imgsecret.rs:530-575`).
|
||||||
|
6. For each block: 2D DCT (`dct2_8x8`, `imgsecret.rs:393-412`) → embed 12 bits
|
||||||
|
into the 12 mid-frequency coefficients listed in `EMBED_POSITIONS`
|
||||||
|
(zig-zag positions 6–17, `imgsecret.rs:105-118`) via QIM with
|
||||||
|
`QUANT_STEP = 50.0` (`imgsecret.rs:462-467`) → 2D inverse DCT → write
|
||||||
|
back into Y.
|
||||||
|
7. `reconstruct_jpeg` (`imgsecret.rs:590-640`) re-derives Cb/Cr per pixel from
|
||||||
|
the original RGB (so chrominance is preserved), combines with the modified
|
||||||
|
Y, and re-encodes at JPEG quality 92.
|
||||||
|
|
||||||
|
### imgsecret extract (with crop recovery)
|
||||||
|
|
||||||
|
1. `extract(jpeg_bytes)` enforces the dimension cap, then delegates to
|
||||||
|
`extract_with_crop_recovery` (`imgsecret.rs:738-741`,
|
||||||
|
`imgsecret.rs:849-899`).
|
||||||
|
2. **Try 1** — assume uncropped: `try_extract_with_layout(&y, w, h, 0, 0)`.
|
||||||
|
This is the hot path; for a freshly embedded image it always succeeds.
|
||||||
|
3. **Try 2** — width-only crop, block-aligned: iterate `orig_w` from current
|
||||||
|
width up to `1.20 × current_w` in 8-px steps, with `dx = 0`
|
||||||
|
(assume right-edge crop).
|
||||||
|
4. **Try 3** — height-only crop, block-aligned: same strategy on the vertical
|
||||||
|
axis.
|
||||||
|
5. **Try 4** — width crops at non-block-aligned 1-px steps, skipping any
|
||||||
|
already covered in Try 2.
|
||||||
|
6. `try_extract_with_layout` (`imgsecret.rs:754-834`) tallies QIM votes for
|
||||||
|
each of the 256 bit positions across all `num_copies` copies. Each bit
|
||||||
|
must reach **≥60% confidence** (`imgsecret.rs:824`); below that, the
|
||||||
|
whole extraction fails with `ExtractionFailed` (no partial result is
|
||||||
|
ever returned).
|
||||||
|
7. The 60% threshold is per-bit, not aggregate — a single unconfident bit
|
||||||
|
aborts the whole try. This makes false-positive extractions from
|
||||||
|
never-embedded images vanishingly unlikely.
|
||||||
|
|
||||||
|
## Cross-cutting concerns
|
||||||
|
|
||||||
|
- **Error model.** `RelicarioError` (`error.rs:15-89`) is a single
|
||||||
|
`thiserror`-derived enum. `Decrypt` is the deliberately-opaque "wrong key
|
||||||
|
or tampered ciphertext" variant (audit M4 — `error.rs:28-30`,
|
||||||
|
`tests/integration.rs:99-111`): the message is just `"decryption failed"`
|
||||||
|
with no inner string, and it does not distinguish wrong-passphrase from
|
||||||
|
wrong-image-secret from corrupted ciphertext. `Format` is the
|
||||||
|
"input bytes don't make sense" variant (e.g. blob too short, schema
|
||||||
|
mismatch). `UnsupportedFormatVersion` is the structured "wrong version
|
||||||
|
byte" variant — separate from `Format` because callers want to react to
|
||||||
|
it differently (offer migration, etc.).
|
||||||
|
- **Where secrets live.** Every secret type wraps `Zeroizing<...>`:
|
||||||
|
- The derived master key: `Zeroizing<[u8; 32]>` (`crypto.rs:212`).
|
||||||
|
- Field values: `FieldValue::Password(Zeroizing<String>)` and
|
||||||
|
`FieldValue::Concealed(Zeroizing<String>)` (`item.rs:39-40`).
|
||||||
|
- `FieldHistoryEntry::value`: `Zeroizing<String>` (`item.rs:127`).
|
||||||
|
- Per-type cores: `LoginCore::password`, `CardCore::{number,cvv,pin}`,
|
||||||
|
`KeyCore::key_material`, `SecureNoteCore::body`, `TotpConfig::secret`
|
||||||
|
(a `Zeroizing<Vec<u8>>` of the raw HMAC key).
|
||||||
|
- Decrypted attachment plaintext: `Zeroizing<Vec<u8>>`
|
||||||
|
(`attachment.rs:88-92`).
|
||||||
|
- Argon2id input buffer (`crypto.rs:232`) and JSON serialization buffers in
|
||||||
|
`vault.rs` are wrapped in `Zeroizing` to wipe the intermediate plaintext.
|
||||||
|
- **Format versioning.** Three independent version channels exist, each
|
||||||
|
gating something different:
|
||||||
|
- `crypto::VERSION_BYTE = 0x02` (`crypto.rs:59`) — gates the AEAD blob
|
||||||
|
layout. Bumped if the nonce length, header layout, or cipher changes.
|
||||||
|
A v1 blob is rejected with a typed
|
||||||
|
`UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`.
|
||||||
|
- `manifest::MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) — gates the
|
||||||
|
JSON-level shape of the manifest. v1 manifests had a different layout
|
||||||
|
and would fail to parse against the current `Manifest` struct.
|
||||||
|
- The `.relbak` import/export format defined in
|
||||||
|
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md`
|
||||||
|
will introduce a third version channel for backups; that surface lives
|
||||||
|
outside this crate.
|
||||||
|
- **KDF parameter handling.** `KdfParams` (`crypto.rs:156-168`) is just a
|
||||||
|
serializable struct. The crate has no opinion about where it is stored,
|
||||||
|
how it is rotated, or who increments it. `Default` gives the production
|
||||||
|
values (`m=65536`, `t=3`, `p=4` — `crypto.rs:175-183`) calibrated for
|
||||||
|
~0.5–1 s on a modern desktop. Tests universally use the fast triplet
|
||||||
|
`(m=256, t=1, p=1)` defined as a `fn fast_params()` near the top of every
|
||||||
|
test file.
|
||||||
|
- **NFC normalization is the only Unicode op.** All passphrase canonicalization
|
||||||
|
happens in one place (`crypto.rs:223-227`). Item titles, field labels,
|
||||||
|
tags, etc. are stored verbatim — only the passphrase fed to the KDF is
|
||||||
|
normalized.
|
||||||
|
- **No per-entry subkeys.** Every encrypted blob (item, manifest, settings,
|
||||||
|
attachment) is encrypted with the *same* master key. The design rationale
|
||||||
|
is in `docs/superpowers/specs/2026-04-11-relicario-design.md` lines 66:
|
||||||
|
per-entry subkey derivation would add complexity for no real-world benefit
|
||||||
|
given the expected family-vault size.
|
||||||
|
- **CSPRNG is `OsRng` everywhere.** `ItemId::new`, `FieldId::new`,
|
||||||
|
`derive_master_key` (no-op — the salt is caller-supplied),
|
||||||
|
`crypto::encrypt` (nonce), `generators::random_password`,
|
||||||
|
`generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
|
||||||
|
inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
|
||||||
|
secret; production code is `OsRng` only.
|
||||||
|
- **`ed25519-dalek` is a dependency placeholder.** Listed in
|
||||||
|
`Cargo.toml:17` but unused in `src/`. It exists for the future
|
||||||
|
device-key surface (`RelicarioError::DeviceKey` is the reserved variant,
|
||||||
|
`error.rs:84-88`); device-key signing currently happens in
|
||||||
|
`relicario-cli` instead.
|
||||||
|
|
||||||
|
## Test architecture
|
||||||
|
|
||||||
|
All `tests/` files use the fast Argon2id triplet `m=256, t=1, p=1` so the
|
||||||
|
suite runs in seconds, not minutes. Test JPEGs are synthesized at runtime via
|
||||||
|
`make_test_jpeg(width, height)` (`imgsecret.rs:908-924`) — a deterministic RGB
|
||||||
|
pattern at quality 92 — so no binary fixtures live in git.
|
||||||
|
|
||||||
|
- **`tests/integration.rs`** — End-to-end vault workflows: encrypt+decrypt a
|
||||||
|
Login and a SecureNote through `Manifest`/`VaultSettings`, two-factor
|
||||||
|
independence (different passphrase or different image_secret yields
|
||||||
|
different keys), field-history surviving an encrypt/decrypt round-trip,
|
||||||
|
and the wrong-key-→-`Decrypt` opaqueness contract.
|
||||||
|
- **`tests/attachments.rs`** — Round-trip a 5 KB blob, prove identical
|
||||||
|
plaintexts produce identical `AttachmentId`s (despite different ciphertext
|
||||||
|
bytes due to fresh nonces), and exercise the cap boundary at exactly the
|
||||||
|
max byte and one over.
|
||||||
|
- **`tests/field_history.rs`** — Sequential `set_field_value` calls accumulate
|
||||||
|
history in oldest→newest order; `prune_history(LastN(3))` keeps the most
|
||||||
|
recent 3; field-history survives `encrypt_item` →`decrypt_item`.
|
||||||
|
- **`tests/format_v2.rs`** — `VERSION_BYTE == 0x02`, fresh ciphertext starts
|
||||||
|
with `0x02`, a v1-shaped blob (`[0x01][24 nonce][16 tag]`) is rejected with
|
||||||
|
the typed `UnsupportedFormatVersion`, and the length-prefix construction
|
||||||
|
prevents `("abc", 0x44…)` / `("abcD", …)` collisions.
|
||||||
|
- **`tests/generators.rs`** — Aggregates 80 × 128 = 10,240 chars from
|
||||||
|
`generate_password` to assert per-character-class proportions are within
|
||||||
|
±5 pp of the expected uniform distribution; verifies that 5-word BIP-39
|
||||||
|
passes the strength gate while common weak passwords ("password",
|
||||||
|
"12345678", "letmein", "qwertyui", "hunter2") all fail; asserts uniqueness
|
||||||
|
across 1000 default-config calls. The opening doc comment
|
||||||
|
(`tests/generators.rs:1-13`) explains why the original "10,000-char single
|
||||||
|
call" plan switched to aggregation: `generate_password` enforces
|
||||||
|
`length ≤ 128`.
|
||||||
|
|
||||||
|
In-module `#[cfg(test)] mod tests` blocks cover unit-level invariants (kind/
|
||||||
|
value mismatches, snake-case serde tags, base32 round-trips, `MonthYear`
|
||||||
|
constructor bounds, the Steam alphabet ambiguity audit). The `imgsecret`
|
||||||
|
test block additionally proves DCT round-tripping, QIM noise tolerance below
|
||||||
|
`Q/4 = 12.5`, embed→Q85-recompress→extract round-trip, embed→10%-crop→extract
|
||||||
|
round-trip, and the oversized-image-header rejection path.
|
||||||
|
|
||||||
|
## Gotchas & non-obvious decisions
|
||||||
|
|
||||||
|
- **`QUANT_STEP = 50.0` is intentionally double the academic value of 25**
|
||||||
|
(`imgsecret.rs:62`). Higher quantization steps make the watermark more robust
|
||||||
|
to JPEG recompression at Q85 and below — at the cost of more visible
|
||||||
|
artifacts in the carrier. The reference image is a personal photo, not a
|
||||||
|
publication, so the trade-off favors robustness.
|
||||||
|
- **The embed region is the *central 70%* (15% margin per side, "crumple
|
||||||
|
zone")** — `imgsecret.rs:212-218`, `imgsecret.rs:276-293`. Anything in the
|
||||||
|
outer 15% is sacrificed so that mild edge crops (e.g. social-media platform
|
||||||
|
trims) leave the embedded data intact. Tested up to 10% crop in
|
||||||
|
`imgsecret.rs:1108-1137`.
|
||||||
|
- **Per-bit majority voting with a 60% confidence floor.**
|
||||||
|
`try_extract_with_layout` tallies votes from every redundant copy and
|
||||||
|
fails the entire extraction if any single bit position is below 60%
|
||||||
|
agreement (`imgsecret.rs:824`). This is more conservative than a global
|
||||||
|
threshold and is what makes false positives from never-embedded images
|
||||||
|
essentially zero — see `extract_from_non_embedded_image_fails`
|
||||||
|
(`imgsecret.rs:1041-1045`).
|
||||||
|
- **Number of redundant copies is capped at 50** (`imgsecret.rs:536`,
|
||||||
|
`imgsecret.rs:692-693`). Beyond that, per-block visual artifacts compound
|
||||||
|
faster than the error-correction benefit grows.
|
||||||
|
- **`peek_jpeg_dimensions` walks JPEG markers manually instead of using the
|
||||||
|
`image` crate.** `imgsecret.rs:127-161`. A full `ImageReader::decode` of an
|
||||||
|
attacker-supplied 30 000 × 30 000 JPEG would allocate ~3.6 GB of pixel
|
||||||
|
buffer in the WASM service worker before failing — the manual walk reads
|
||||||
|
only the SOF segment and bails in O(marker-count) (audit M3).
|
||||||
|
- **`bip39` always generates 128 bits of entropy** (12 mnemonic words) and
|
||||||
|
truncates to `word_count` (`generators.rs:82-89`). This is because
|
||||||
|
`bip39 v2` rejects entropy below 128 bits, but we want to support 3–12 word
|
||||||
|
passphrases. Truncation preserves the per-word independence — the words
|
||||||
|
the user sees still come from a uniformly-sampled-then-truncated 12-word
|
||||||
|
draw.
|
||||||
|
- **Steam TOTP output is exactly 5 characters from a 26-glyph alphabet,
|
||||||
|
regardless of the `digits` field on `TotpConfig`** (`item_types/totp.rs:103-110`,
|
||||||
|
asserted in `item_types/totp.rs:240-253`). The alphabet
|
||||||
|
(`23456789BCDFGHJKMNPQRTVWXY`) excludes `0/O`, `1/I/L`, `S` (so `5` is
|
||||||
|
unambiguous), `A`, `E`, `U`, `Z` — all glyphs Valve considered ambiguous
|
||||||
|
in the Steam Mobile Authenticator. Verified at
|
||||||
|
`item_types/totp.rs:274-283`.
|
||||||
|
- **`ItemCore` is internally-tagged with `#[serde(tag = "type")]`** — the
|
||||||
|
outer JSON object gets a `"type"` key. This means *no* `*Core` struct may
|
||||||
|
have a field literally named `type`. The convention chosen for
|
||||||
|
type-discriminant fields *inside* a core is `kind` — see `CardKind`,
|
||||||
|
`TotpKind` (`item_types/mod.rs:38-40`).
|
||||||
|
- **The TOTP base32 in field-history strips padding.** `base32_encode`
|
||||||
|
(`item.rs:256-275`) is RFC-4648 with no `=` padding — appropriate because
|
||||||
|
the value is for human display in history, not for re-decoding.
|
||||||
|
- **`AttachmentId::from_plaintext` uses only the first 8 bytes (= 16 hex
|
||||||
|
chars) of the SHA-256 digest** (`ids.rs:51-57`). 64 bits of collision
|
||||||
|
resistance is sufficient for a personal-vault attachment count; it keeps
|
||||||
|
filenames short. If a future use case demands collision resistance against
|
||||||
|
motivated adversaries (e.g. dedup across untrusted vaults), this width is
|
||||||
|
the lever.
|
||||||
|
- **`Field::new` derives `kind` from `value`, but the public struct still
|
||||||
|
stores both** (`item.rs:73-94`). The duplication exists so callers can
|
||||||
|
match on `kind` without inspecting (and potentially decrypting / cloning)
|
||||||
|
`value`. `validate()` is the safety net that runs after deserialization.
|
||||||
|
- **`set_field_value` refuses to change a field's kind** (`item.rs:184-189`).
|
||||||
|
The intent is that fields are conceptually fixed-shape after creation;
|
||||||
|
changing a `Text` to a `Password` should be done by deleting the old field
|
||||||
|
and creating a new one (so history doesn't get confused).
|
||||||
|
- **`hidden_by_default` is *not* `Zeroize`.** It's purely a UI hint — the
|
||||||
|
rendering layer (CLI output, popup card) decides whether to mask the value
|
||||||
|
on initial display. Secrecy at rest is enforced by the `Zeroizing` wrappers
|
||||||
|
on the value itself, not this flag.
|
||||||
|
- **`Manifest::upsert` rebuilds the entry from scratch every call**
|
||||||
|
(`manifest.rs:45-48`, `manifest.rs:75-89`). There is no "patch the
|
||||||
|
existing entry" path. This means the manifest can never carry a stale
|
||||||
|
`icon_hint` or `attachment_summaries` — they are derived freshly from the
|
||||||
|
source `Item` each time.
|
||||||
|
- **The strength gate is *not* called inside `derive_master_key`.** It must
|
||||||
|
be invoked separately by the caller during *vault creation* only — not
|
||||||
|
during unlock, where calling it would let an attacker probe whether a
|
||||||
|
wrong passphrase happens to be "strong enough" before the Argon2id work
|
||||||
|
even starts. See `generators.rs:124-130`.
|
||||||
|
- **`now_unix()` is `chrono::Utc::now().timestamp()` and is the single time
|
||||||
|
source in this crate** (`time.rs:6-8`). Tests that need determinism pass an
|
||||||
|
explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they
|
||||||
|
do not stub `now_unix`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-core"
|
name = "relicario-core"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Core library for relicario password manager"
|
description = "Core library for relicario password manager"
|
||||||
|
|
||||||
@@ -26,5 +26,9 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
url = { version = "2", features = ["serde"] }
|
url = { version = "2", features = ["serde"] }
|
||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
|
zstd = { version = "0.13", default-features = false }
|
||||||
|
tar = { version = "0.4", default-features = false }
|
||||||
|
base64 = "0.22"
|
||||||
|
csv = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
340
crates/relicario-core/src/backup.rs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
//! Backup container — encrypted, compressed, single-file archive of a vault.
|
||||||
|
//!
|
||||||
|
//! ## Format (v1)
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [magic "RBAK" 4 bytes][version 0x01 1 byte][salt 32 bytes][nonce 24 bytes][ciphertext+tag]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! After AEAD decryption, the plaintext is zstd-compressed bytes whose
|
||||||
|
//! decompressed form is a UTF-8 JSON document — see [`Envelope`].
|
||||||
|
//!
|
||||||
|
//! The backup container key is **independent** of any vault master key.
|
||||||
|
//! The user picks a backup passphrase at export and types it at restore.
|
||||||
|
//! Argon2id parameters are pinned to v1-of-this-format (m=64MiB, t=3, p=4)
|
||||||
|
//! so a v1 reader does not need to negotiate them.
|
||||||
|
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use base64::Engine;
|
||||||
|
use chacha20poly1305::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
XChaCha20Poly1305, XNonce,
|
||||||
|
};
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// File-level magic. Four bytes so a `file(1)` rule can identify it.
|
||||||
|
pub const MAGIC: [u8; 4] = *b"RBAK";
|
||||||
|
|
||||||
|
/// Container format version. Bumped if the on-disk layout of the
|
||||||
|
/// salt/nonce/ciphertext header or the AEAD primitive changes.
|
||||||
|
pub const FORMAT_VERSION: u8 = 0x01;
|
||||||
|
|
||||||
|
/// JSON envelope schema version. Bumped if the JSON shape changes
|
||||||
|
/// without an underlying-format change (e.g. new optional fields whose
|
||||||
|
/// absence v1 readers can tolerate would NOT bump this; renames or
|
||||||
|
/// removals would).
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
const SALT_LEN: usize = 32;
|
||||||
|
const NONCE_LEN: usize = 24;
|
||||||
|
const TAG_LEN: usize = 16;
|
||||||
|
const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // magic + version + salt + nonce
|
||||||
|
|
||||||
|
const ARGON2_M_KIB: u32 = 65_536; // 64 MiB
|
||||||
|
const ARGON2_T: u32 = 3;
|
||||||
|
const ARGON2_P: u32 = 4;
|
||||||
|
|
||||||
|
/// Zstd compression level. 3 is the speed/size sweet spot.
|
||||||
|
const ZSTD_LEVEL: i32 = 3;
|
||||||
|
|
||||||
|
/// Inputs to [`pack_backup`]. Borrow-only — the caller retains ownership of
|
||||||
|
/// every byte slice.
|
||||||
|
pub struct BackupInput<'a> {
|
||||||
|
/// Raw 32-byte vault salt (`.relicario/salt` contents).
|
||||||
|
pub salt: &'a [u8],
|
||||||
|
/// Verbatim string contents of `.relicario/params.json`.
|
||||||
|
pub params_json: &'a str,
|
||||||
|
/// Verbatim string contents of `.relicario/devices.json`.
|
||||||
|
pub devices_json: &'a str,
|
||||||
|
/// Encrypted manifest bytes (verbatim `manifest.enc`).
|
||||||
|
pub manifest_enc: &'a [u8],
|
||||||
|
/// Encrypted vault settings bytes (verbatim `settings.enc`).
|
||||||
|
pub settings_enc: &'a [u8],
|
||||||
|
/// One entry per item file (verbatim ciphertext).
|
||||||
|
pub items: Vec<BackupItem<'a>>,
|
||||||
|
/// One entry per attachment blob (verbatim ciphertext).
|
||||||
|
pub attachments: Vec<BackupAttachment<'a>>,
|
||||||
|
/// Reference JPEG bytes — included iff caller wants to bundle the
|
||||||
|
/// second factor.
|
||||||
|
pub reference_jpg: Option<&'a [u8]>,
|
||||||
|
/// Tarred `.git/` directory — included iff caller wants the audit log.
|
||||||
|
/// The caller (CLI) does the actual tarring; core just transports the
|
||||||
|
/// opaque bytes.
|
||||||
|
pub git_archive: Option<&'a [u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One vault item ciphertext, keyed by the item id (16-char hex).
|
||||||
|
pub struct BackupItem<'a> {
|
||||||
|
pub id: String,
|
||||||
|
pub ciphertext: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One attachment blob, keyed by `<item_id>/<attachment_id>` so the
|
||||||
|
/// per-item directory layout round-trips.
|
||||||
|
pub struct BackupAttachment<'a> {
|
||||||
|
pub item_id: String,
|
||||||
|
pub attachment_id: String,
|
||||||
|
pub ciphertext: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`unpack_backup`]. Owned bytes — the caller decides where to
|
||||||
|
/// persist them.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BackupOutput {
|
||||||
|
pub salt: [u8; 32],
|
||||||
|
pub params_json: String,
|
||||||
|
pub devices_json: String,
|
||||||
|
pub manifest_enc: Vec<u8>,
|
||||||
|
pub settings_enc: Vec<u8>,
|
||||||
|
pub items: Vec<UnpackedItem>,
|
||||||
|
pub attachments: Vec<UnpackedAttachment>,
|
||||||
|
pub reference_jpg: Option<Vec<u8>>,
|
||||||
|
pub git_archive: Option<Vec<u8>>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UnpackedItem {
|
||||||
|
pub id: String,
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UnpackedAttachment {
|
||||||
|
pub item_id: String,
|
||||||
|
pub attachment_id: String,
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Envelope {
|
||||||
|
schema_version: u32,
|
||||||
|
created_at: i64,
|
||||||
|
vault: VaultEnvelope,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct VaultEnvelope {
|
||||||
|
/// base64-encoded 32-byte vault salt.
|
||||||
|
salt: String,
|
||||||
|
/// Verbatim params.json contents (string, not nested object — keeps
|
||||||
|
/// forward-compat with future params.json schema changes opaque to
|
||||||
|
/// the backup format).
|
||||||
|
params: String,
|
||||||
|
/// Verbatim devices.json contents (string for the same reason).
|
||||||
|
devices: String,
|
||||||
|
/// base64-encoded ciphertext of `manifest.enc`.
|
||||||
|
manifest: String,
|
||||||
|
/// base64-encoded ciphertext of `settings.enc`.
|
||||||
|
settings: String,
|
||||||
|
/// Map of `item_id` → base64-encoded item ciphertext.
|
||||||
|
items: std::collections::BTreeMap<String, String>,
|
||||||
|
/// Map of `<item_id>/<attachment_id>` → base64-encoded ciphertext.
|
||||||
|
attachments: std::collections::BTreeMap<String, String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
reference_jpg: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
git_archive: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack a vault into the `.relbak` container.
|
||||||
|
///
|
||||||
|
/// Generates fresh 32-byte salt + 24-byte nonce via OsRng. Derives a
|
||||||
|
/// 32-byte key via Argon2id with the format-pinned parameters, then
|
||||||
|
/// XChaCha20-Poly1305 encrypts the zstd-compressed JSON envelope.
|
||||||
|
pub fn pack_backup(input: BackupInput<'_>, passphrase: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||||
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
|
|
||||||
|
let key = derive_backup_key(passphrase.as_bytes(), &salt)?;
|
||||||
|
|
||||||
|
let envelope = build_envelope(input, crate::time::now_unix())?;
|
||||||
|
let json = serde_json::to_vec(&envelope)?;
|
||||||
|
|
||||||
|
let compressed = zstd::encode_all(&json[..], ZSTD_LEVEL)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("zstd compress: {e}")))?;
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new((&*key).into());
|
||||||
|
let nonce = XNonce::from(nonce_bytes);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, compressed.as_slice())
|
||||||
|
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||||
|
out.extend_from_slice(&MAGIC);
|
||||||
|
out.push(FORMAT_VERSION);
|
||||||
|
out.extend_from_slice(&salt);
|
||||||
|
out.extend_from_slice(&nonce_bytes);
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack a `.relbak` container, verifying magic + version, decrypting,
|
||||||
|
/// decompressing, and parsing the JSON envelope.
|
||||||
|
pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result<BackupOutput> {
|
||||||
|
if data.len() < HEADER_LEN + TAG_LEN {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
"backup file truncated".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if data[0..4] != MAGIC {
|
||||||
|
return Err(RelicarioError::BackupBadMagic);
|
||||||
|
}
|
||||||
|
let version = data[4];
|
||||||
|
if version != FORMAT_VERSION {
|
||||||
|
return Err(RelicarioError::BackupUnsupportedVersion {
|
||||||
|
found: version,
|
||||||
|
expected: FORMAT_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
salt.copy_from_slice(&data[5..5 + SALT_LEN]);
|
||||||
|
let nonce_start = 5 + SALT_LEN;
|
||||||
|
let nonce_bytes: &[u8] = &data[nonce_start..nonce_start + NONCE_LEN];
|
||||||
|
let ciphertext = &data[HEADER_LEN..];
|
||||||
|
|
||||||
|
let key = derive_backup_key(passphrase.as_bytes(), &salt)?;
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new((&*key).into());
|
||||||
|
let nonce = XNonce::from_slice(nonce_bytes);
|
||||||
|
let compressed = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|_| RelicarioError::Decrypt)?;
|
||||||
|
|
||||||
|
let json_bytes = zstd::decode_all(compressed.as_slice())
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("zstd decompress: {e}")))?;
|
||||||
|
|
||||||
|
let env: Envelope = serde_json::from_slice(&json_bytes)?;
|
||||||
|
if env.schema_version != SCHEMA_VERSION {
|
||||||
|
return Err(RelicarioError::BackupSchemaMismatch {
|
||||||
|
found: env.schema_version,
|
||||||
|
expected: SCHEMA_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let mut salt_out = [0u8; 32];
|
||||||
|
let salt_decoded = b64
|
||||||
|
.decode(&env.vault.salt)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 salt: {e}")))?;
|
||||||
|
if salt_decoded.len() != 32 {
|
||||||
|
return Err(RelicarioError::Format(format!(
|
||||||
|
"salt length: expected 32, got {}",
|
||||||
|
salt_decoded.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
salt_out.copy_from_slice(&salt_decoded);
|
||||||
|
|
||||||
|
let manifest_enc = b64
|
||||||
|
.decode(&env.vault.manifest)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 manifest: {e}")))?;
|
||||||
|
let settings_enc = b64
|
||||||
|
.decode(&env.vault.settings)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 settings: {e}")))?;
|
||||||
|
|
||||||
|
let mut items = Vec::with_capacity(env.vault.items.len());
|
||||||
|
for (id, b64_ct) in env.vault.items {
|
||||||
|
let ct = b64
|
||||||
|
.decode(&b64_ct)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 item {id}: {e}")))?;
|
||||||
|
items.push(UnpackedItem { id, ciphertext: ct });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut attachments = Vec::with_capacity(env.vault.attachments.len());
|
||||||
|
for (combined, b64_ct) in env.vault.attachments {
|
||||||
|
let (item_id, attachment_id) = combined
|
||||||
|
.split_once('/')
|
||||||
|
.map(|(a, b)| (a.to_string(), b.to_string()))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
RelicarioError::Format(format!("bad attachment key '{combined}'"))
|
||||||
|
})?;
|
||||||
|
let ct = b64
|
||||||
|
.decode(&b64_ct)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 attachment {combined}: {e}")))?;
|
||||||
|
attachments.push(UnpackedAttachment { item_id, attachment_id, ciphertext: ct });
|
||||||
|
}
|
||||||
|
|
||||||
|
let reference_jpg = env
|
||||||
|
.vault
|
||||||
|
.reference_jpg
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 reference_jpg: {e}")))?;
|
||||||
|
let git_archive = env
|
||||||
|
.vault
|
||||||
|
.git_archive
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 git_archive: {e}")))?;
|
||||||
|
|
||||||
|
Ok(BackupOutput {
|
||||||
|
salt: salt_out,
|
||||||
|
params_json: env.vault.params,
|
||||||
|
devices_json: env.vault.devices,
|
||||||
|
manifest_enc,
|
||||||
|
settings_enc,
|
||||||
|
items,
|
||||||
|
attachments,
|
||||||
|
reference_jpg,
|
||||||
|
git_archive,
|
||||||
|
created_at: env.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
|
||||||
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
|
||||||
|
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
argon
|
||||||
|
.hash_password_into(passphrase, salt, key.as_mut_slice())
|
||||||
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_envelope(input: BackupInput<'_>, created_at: i64) -> Result<Envelope> {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let mut items = std::collections::BTreeMap::new();
|
||||||
|
for it in input.items {
|
||||||
|
items.insert(it.id, b64.encode(it.ciphertext));
|
||||||
|
}
|
||||||
|
let mut attachments = std::collections::BTreeMap::new();
|
||||||
|
for a in input.attachments {
|
||||||
|
let key = format!("{}/{}", a.item_id, a.attachment_id);
|
||||||
|
attachments.insert(key, b64.encode(a.ciphertext));
|
||||||
|
}
|
||||||
|
Ok(Envelope {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
created_at,
|
||||||
|
vault: VaultEnvelope {
|
||||||
|
salt: b64.encode(input.salt),
|
||||||
|
params: input.params_json.to_string(),
|
||||||
|
devices: input.devices_json.to_string(),
|
||||||
|
manifest: b64.encode(input.manifest_enc),
|
||||||
|
settings: b64.encode(input.settings_enc),
|
||||||
|
items,
|
||||||
|
attachments,
|
||||||
|
reference_jpg: input.reference_jpg.map(|b| b64.encode(b)),
|
||||||
|
git_archive: input.git_archive.map(|b| b64.encode(b)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Unified error type for the relicario-core crate.
|
//! Unified error type for the Relicario core crate.
|
||||||
//!
|
//!
|
||||||
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
|
//! 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
|
//! for `std::result::Result<T, RelicarioError>`. Using a single error enum keeps the
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// All errors that can originate from relicario-core operations.
|
/// All errors that can originate from Relicario core operations.
|
||||||
///
|
///
|
||||||
/// Variants are ordered roughly by the pipeline stage where they occur:
|
/// Variants are ordered roughly by the pipeline stage where they occur:
|
||||||
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
|
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
|
||||||
@@ -39,6 +39,29 @@ pub enum RelicarioError {
|
|||||||
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
|
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
|
||||||
UnsupportedFormatVersion { found: u8, expected: u8 },
|
UnsupportedFormatVersion { found: u8, expected: u8 },
|
||||||
|
|
||||||
|
/// Backup file's first 4 bytes don't match the "RBAK" magic.
|
||||||
|
#[error("not a Relicario backup file")]
|
||||||
|
BackupBadMagic,
|
||||||
|
|
||||||
|
/// Backup format version is newer than this binary supports.
|
||||||
|
#[error("backup created by a newer Relicario; upgrade required")]
|
||||||
|
BackupUnsupportedVersion { found: u8, expected: u8 },
|
||||||
|
|
||||||
|
/// Backup envelope schema version doesn't match.
|
||||||
|
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
||||||
|
BackupSchemaMismatch { found: u32, expected: u32 },
|
||||||
|
|
||||||
|
/// CSV header doesn't match the LastPass column layout.
|
||||||
|
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
||||||
|
ImportCsvHeader(String),
|
||||||
|
|
||||||
|
/// CSV body could not be parsed (mismatched quoting, encoding, etc.).
|
||||||
|
/// Per-row record errors that the importer recovers from become
|
||||||
|
/// `ImportWarning` entries — this variant is reserved for failures
|
||||||
|
/// that abort the whole import.
|
||||||
|
#[error("CSV parse failed: {0}")]
|
||||||
|
ImportCsvFormat(String),
|
||||||
|
|
||||||
/// An item was looked up by ID but does not exist in the manifest.
|
/// An item was looked up by ID but does not exist in the manifest.
|
||||||
#[error("item not found: {0}")]
|
#[error("item not found: {0}")]
|
||||||
ItemNotFound(String),
|
ItemNotFound(String),
|
||||||
@@ -130,4 +153,29 @@ mod tests {
|
|||||||
assert!(s.contains("01") || s.contains("1"));
|
assert!(s.contains("01") || s.contains("1"));
|
||||||
assert!(s.contains("02") || s.contains("2"));
|
assert!(s.contains("02") || s.contains("2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_errors_carry_useful_messages() {
|
||||||
|
let bad = RelicarioError::BackupBadMagic;
|
||||||
|
assert!(format!("{}", bad).contains("not a Relicario backup file"));
|
||||||
|
|
||||||
|
let ver = RelicarioError::BackupUnsupportedVersion { found: 0x02, expected: 0x01 };
|
||||||
|
let s = format!("{}", ver);
|
||||||
|
assert!(s.contains("newer"));
|
||||||
|
|
||||||
|
let schema = RelicarioError::BackupSchemaMismatch { found: 2, expected: 1 };
|
||||||
|
let s = format!("{}", schema);
|
||||||
|
assert!(s.contains("v2") && s.contains("v1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_errors_carry_useful_messages() {
|
||||||
|
let h = RelicarioError::ImportCsvHeader("missing 'name' column".into());
|
||||||
|
assert!(format!("{}", h).contains("LastPass"));
|
||||||
|
assert!(format!("{}", h).contains("missing 'name'"));
|
||||||
|
|
||||||
|
let f = RelicarioError::ImportCsvFormat("unterminated quote at line 12".into());
|
||||||
|
assert!(format!("{}", f).contains("CSV parse failed"));
|
||||||
|
assert!(format!("{}", f).contains("unterminated quote"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
220
crates/relicario-core/src/import_lastpass.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! LastPass CSV importer.
|
||||||
|
//!
|
||||||
|
//! Pure: takes CSV bytes, returns a vector of `Item` (with freshly-minted
|
||||||
|
//! IDs and timestamps) plus a vector of `ImportWarning` for skipped or
|
||||||
|
//! partially-imported rows. Failed rows never abort the whole import;
|
||||||
|
//! the only fatal error is a missing or malformed header.
|
||||||
|
//!
|
||||||
|
//! Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md
|
||||||
|
//! (D10–D13 + the LastPass field-mapping table).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
use crate::item::Item;
|
||||||
|
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
|
||||||
|
|
||||||
|
/// LastPass column order. The header row must contain these exact column
|
||||||
|
/// names in this exact order.
|
||||||
|
pub const EXPECTED_HEADER: &[&str] =
|
||||||
|
&["url", "username", "password", "totp", "extra", "name", "grouping", "fav"];
|
||||||
|
|
||||||
|
/// A row that was skipped, or partially imported with a downgrade
|
||||||
|
/// (e.g., login imported without TOTP).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImportWarning {
|
||||||
|
/// 1-indexed row number in the CSV body (the header is row 0).
|
||||||
|
pub row: usize,
|
||||||
|
/// Title from the row's `name` column, if present and non-empty.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Human-readable explanation, suitable for stderr / inline UI.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a LastPass CSV export.
|
||||||
|
///
|
||||||
|
/// Returns the parsed items (with fresh IDs and timestamps) and any
|
||||||
|
/// per-row warnings. The function only fails if the header is missing
|
||||||
|
/// or doesn't match `EXPECTED_HEADER`.
|
||||||
|
pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec<Item>, Vec<ImportWarning>)> {
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(true)
|
||||||
|
.flexible(false)
|
||||||
|
.from_reader(csv_bytes);
|
||||||
|
|
||||||
|
// Validate header.
|
||||||
|
let headers = reader
|
||||||
|
.headers()
|
||||||
|
.map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))?
|
||||||
|
.clone();
|
||||||
|
if headers.len() != EXPECTED_HEADER.len()
|
||||||
|
|| headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want)
|
||||||
|
{
|
||||||
|
return Err(RelicarioError::ImportCsvHeader(format!(
|
||||||
|
"expected `{}`, got `{}`",
|
||||||
|
EXPECTED_HEADER.join(","),
|
||||||
|
headers.iter().collect::<Vec<_>>().join(",")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
for (idx, record) in reader.records().enumerate() {
|
||||||
|
let row_num = idx + 1;
|
||||||
|
let record = match record {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warnings.push(ImportWarning {
|
||||||
|
row: row_num,
|
||||||
|
title: None,
|
||||||
|
message: format!("CSV parse error — skipped: {e}"),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (item, warn) = map_row(&record, row_num);
|
||||||
|
if let Some(it) = item { items.push(it); }
|
||||||
|
if let Some(w) = warn { warnings.push(w); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((items, warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a single CSV record. Returns:
|
||||||
|
/// - `(Some(item), None)` for a fully-imported row.
|
||||||
|
/// - `(Some(item), Some(warn))` for a partially-imported row (e.g.,
|
||||||
|
/// bad TOTP base32 — login imported without TOTP).
|
||||||
|
/// - `(None, Some(warn))` for a skipped row (missing required field).
|
||||||
|
fn map_row(
|
||||||
|
record: &csv::StringRecord,
|
||||||
|
row: usize,
|
||||||
|
) -> (Option<Item>, Option<ImportWarning>) {
|
||||||
|
let url = record.get(0).unwrap_or("").trim();
|
||||||
|
let username = record.get(1).unwrap_or("").trim();
|
||||||
|
// password and extra are deliberately NOT trimmed: leading/trailing
|
||||||
|
// whitespace is significant inside passwords and free-form notes.
|
||||||
|
let password = record.get(2).unwrap_or("");
|
||||||
|
let totp_raw = record.get(3).unwrap_or("").trim();
|
||||||
|
let extra = record.get(4).unwrap_or("");
|
||||||
|
let name = record.get(5).unwrap_or("").trim();
|
||||||
|
let group = record.get(6).unwrap_or("").trim();
|
||||||
|
let fav = record.get(7).unwrap_or("").trim();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return (None, Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: None,
|
||||||
|
message: "missing `name` — skipped".into(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecureNote marker: LastPass exports notes with `url` set to "http://sn".
|
||||||
|
// The `extra` column carries the body verbatim.
|
||||||
|
if url == "http://sn" {
|
||||||
|
let mut item = Item::new(
|
||||||
|
name.to_string(),
|
||||||
|
ItemCore::SecureNote(SecureNoteCore {
|
||||||
|
body: Zeroizing::new(extra.to_string()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
|
||||||
|
item.favorite = fav == "1";
|
||||||
|
return (Some(item), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return (None, Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: Some(name.to_string()),
|
||||||
|
message: "missing `password` — skipped".into(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut warning: Option<ImportWarning> = None;
|
||||||
|
|
||||||
|
let parsed_url = if url.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match Url::parse(url) {
|
||||||
|
Ok(u) => Some(u),
|
||||||
|
Err(_) => {
|
||||||
|
// Login still imports — URL becomes None, with a warning.
|
||||||
|
if warning.is_none() {
|
||||||
|
warning = Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: Some(name.to_string()),
|
||||||
|
message: format!("invalid URL `{url}` — login imported without URL"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let totp = if totp_raw.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match decode_base32_totp(totp_raw) {
|
||||||
|
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
||||||
|
secret: Zeroizing::new(bytes),
|
||||||
|
algorithm: crate::item_types::TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: crate::item_types::TotpKind::Totp,
|
||||||
|
}),
|
||||||
|
_ => {
|
||||||
|
if warning.is_none() {
|
||||||
|
warning = Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: Some(name.to_string()),
|
||||||
|
message: "invalid base32 TOTP secret — login imported without TOTP"
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut item = Item::new(
|
||||||
|
name.to_string(),
|
||||||
|
ItemCore::Login(LoginCore {
|
||||||
|
username: if username.is_empty() { None } else { Some(username.to_string()) },
|
||||||
|
password: Some(Zeroizing::new(password.to_string())),
|
||||||
|
url: parsed_url,
|
||||||
|
totp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
|
||||||
|
item.favorite = fav == "1";
|
||||||
|
item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) };
|
||||||
|
|
||||||
|
(Some(item), warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive,
|
||||||
|
/// padding optional. Returns None if the input contains any non-alphabet
|
||||||
|
/// character (after upper-casing). Used by the LastPass importer.
|
||||||
|
fn decode_base32_totp(secret: &str) -> Option<Vec<u8>> {
|
||||||
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
|
||||||
|
if upper.is_empty() { return None; }
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for ch in upper.bytes() {
|
||||||
|
let idx = ALPHA.iter().position(|&a| a == ch)?;
|
||||||
|
buffer = (buffer << 5) | (idx as u32);
|
||||||
|
bits += 5;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push(((buffer >> bits) & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! # relicario-core
|
//! # relicario-core
|
||||||
//!
|
//!
|
||||||
//! Platform-agnostic core library for the relicario password manager.
|
//! Platform-agnostic core library for the Relicario password manager.
|
||||||
//!
|
//!
|
||||||
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
|
//! 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
|
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
|
||||||
@@ -77,3 +77,9 @@ pub use vault::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub mod imgsecret;
|
pub mod imgsecret;
|
||||||
|
|
||||||
|
pub mod backup;
|
||||||
|
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
||||||
|
|
||||||
|
pub mod import_lastpass;
|
||||||
|
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
|||||||
188
crates/relicario-core/tests/backup.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
//! Backup container round-trip + error-path coverage.
|
||||||
|
|
||||||
|
use relicario_core::backup::{pack_backup, unpack_backup, BackupInput};
|
||||||
|
|
||||||
|
fn empty_input() -> BackupInput<'static> {
|
||||||
|
BackupInput {
|
||||||
|
salt: &[0u8; 32],
|
||||||
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
||||||
|
devices_json: "[]",
|
||||||
|
manifest_enc: &[],
|
||||||
|
settings_enc: &[],
|
||||||
|
items: vec![],
|
||||||
|
attachments: vec![],
|
||||||
|
reference_jpg: None,
|
||||||
|
git_archive: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_vault_round_trip() {
|
||||||
|
let out = pack_backup(empty_input(), "test-passphrase-1234").unwrap();
|
||||||
|
assert_eq!(&out[..4], b"RBAK", "magic header");
|
||||||
|
assert_eq!(out[4], 0x01, "format version");
|
||||||
|
|
||||||
|
let unpacked = unpack_backup(&out, "test-passphrase-1234").unwrap();
|
||||||
|
assert_eq!(unpacked.salt, [0u8; 32]);
|
||||||
|
assert!(unpacked.devices_json.contains("[]"));
|
||||||
|
assert!(unpacked.items.is_empty());
|
||||||
|
assert!(unpacked.attachments.is_empty());
|
||||||
|
assert!(unpacked.reference_jpg.is_none());
|
||||||
|
assert!(unpacked.git_archive.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
use relicario_core::backup::{BackupAttachment, BackupItem};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn populated_vault_round_trip() {
|
||||||
|
let manifest_enc = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||||
|
let settings_enc = vec![0x01, 0x02, 0x03];
|
||||||
|
let item_a_ct = vec![0xAA; 100];
|
||||||
|
let item_b_ct = vec![0xBB; 200];
|
||||||
|
let attach_x_ct = vec![0xCC; 4096];
|
||||||
|
let attach_y_ct = vec![0xDD; 8192];
|
||||||
|
|
||||||
|
let input = BackupInput {
|
||||||
|
salt: &[0x77u8; 32],
|
||||||
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
||||||
|
devices_json: r#"[{"name":"laptop","public_key":"deadbeef"}]"#,
|
||||||
|
manifest_enc: &manifest_enc,
|
||||||
|
settings_enc: &settings_enc,
|
||||||
|
items: vec![
|
||||||
|
BackupItem { id: "1111111111111111".to_string(), ciphertext: &item_a_ct },
|
||||||
|
BackupItem { id: "2222222222222222".to_string(), ciphertext: &item_b_ct },
|
||||||
|
],
|
||||||
|
attachments: vec![
|
||||||
|
BackupAttachment {
|
||||||
|
item_id: "1111111111111111".to_string(),
|
||||||
|
attachment_id: "aaaa1111".to_string(),
|
||||||
|
ciphertext: &attach_x_ct,
|
||||||
|
},
|
||||||
|
BackupAttachment {
|
||||||
|
item_id: "2222222222222222".to_string(),
|
||||||
|
attachment_id: "bbbb2222".to_string(),
|
||||||
|
ciphertext: &attach_y_ct,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reference_jpg: None,
|
||||||
|
git_archive: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = pack_backup(input, "another-strong-passphrase").unwrap();
|
||||||
|
let unpacked = unpack_backup(&out, "another-strong-passphrase").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unpacked.salt, [0x77u8; 32]);
|
||||||
|
assert!(unpacked.devices_json.contains("laptop"));
|
||||||
|
assert_eq!(unpacked.manifest_enc, manifest_enc);
|
||||||
|
assert_eq!(unpacked.settings_enc, settings_enc);
|
||||||
|
|
||||||
|
assert_eq!(unpacked.items.len(), 2);
|
||||||
|
let by_id: std::collections::HashMap<_, _> =
|
||||||
|
unpacked.items.iter().map(|i| (i.id.as_str(), &i.ciphertext)).collect();
|
||||||
|
assert_eq!(by_id.get("1111111111111111").unwrap(), &&item_a_ct);
|
||||||
|
assert_eq!(by_id.get("2222222222222222").unwrap(), &&item_b_ct);
|
||||||
|
|
||||||
|
assert_eq!(unpacked.attachments.len(), 2);
|
||||||
|
let by_aid: std::collections::HashMap<_, _> = unpacked
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.map(|a| ((a.item_id.as_str(), a.attachment_id.as_str()), &a.ciphertext))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(by_aid.get(&("1111111111111111", "aaaa1111")).unwrap(), &&attach_x_ct);
|
||||||
|
assert_eq!(by_aid.get(&("2222222222222222", "bbbb2222")).unwrap(), &&attach_y_ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_with_reference_image() {
|
||||||
|
let jpg_bytes: Vec<u8> = (0u8..=255).cycle().take(1024 * 64).collect(); // 64 KiB
|
||||||
|
let mut input = empty_input();
|
||||||
|
input.reference_jpg = Some(&jpg_bytes);
|
||||||
|
|
||||||
|
let out = pack_backup(input, "p").unwrap();
|
||||||
|
let unpacked = unpack_backup(&out, "p").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unpacked.reference_jpg.as_deref(), Some(jpg_bytes.as_slice()));
|
||||||
|
assert!(unpacked.git_archive.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_with_git_archive() {
|
||||||
|
let tar_bytes: Vec<u8> = b"FAKE TAR BYTES; core treats opaquely".repeat(50);
|
||||||
|
let mut input = empty_input();
|
||||||
|
input.git_archive = Some(&tar_bytes);
|
||||||
|
|
||||||
|
let out = pack_backup(input, "p").unwrap();
|
||||||
|
let unpacked = unpack_backup(&out, "p").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unpacked.git_archive.as_deref(), Some(tar_bytes.as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_history_produces_strict_subset() {
|
||||||
|
let mut a = empty_input();
|
||||||
|
a.git_archive = Some(b"some-tar-bytes");
|
||||||
|
let with = pack_backup(a, "p").unwrap();
|
||||||
|
|
||||||
|
let without = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
|
||||||
|
// The "without" file is strictly smaller (one fewer base64-encoded blob in JSON).
|
||||||
|
assert!(without.len() < with.len(),
|
||||||
|
"no-history backup should be smaller: with={}, without={}",
|
||||||
|
with.len(), without.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
use relicario_core::RelicarioError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_magic_rejected() {
|
||||||
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
bytes[0] = b'X';
|
||||||
|
match unpack_backup(&bytes, "p") {
|
||||||
|
Err(RelicarioError::BackupBadMagic) => {}
|
||||||
|
other => panic!("expected BackupBadMagic, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_version_rejected() {
|
||||||
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
bytes[4] = 0xFF;
|
||||||
|
match unpack_backup(&bytes, "p") {
|
||||||
|
Err(RelicarioError::BackupUnsupportedVersion { found, expected }) => {
|
||||||
|
assert_eq!(found, 0xFF);
|
||||||
|
assert_eq!(expected, 0x01);
|
||||||
|
}
|
||||||
|
other => panic!("expected BackupUnsupportedVersion, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_passphrase_rejected_as_decrypt_error() {
|
||||||
|
let bytes = pack_backup(empty_input(), "right-passphrase").unwrap();
|
||||||
|
match unpack_backup(&bytes, "wrong-passphrase") {
|
||||||
|
Err(RelicarioError::Decrypt) => {}
|
||||||
|
other => panic!("expected Decrypt (opaque), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_file_rejected() {
|
||||||
|
let bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
let truncated = &bytes[..bytes.len().min(60)]; // shorter than HEADER_LEN + TAG_LEN
|
||||||
|
match unpack_backup(truncated, "p") {
|
||||||
|
Err(RelicarioError::Format(_)) => {}
|
||||||
|
other => panic!("expected Format(truncated), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_ciphertext_rejected_as_decrypt_error() {
|
||||||
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
let last = bytes.len() - 1;
|
||||||
|
bytes[last] ^= 0xFF; // flip a byte in the auth-tag region
|
||||||
|
match unpack_backup(&bytes, "p") {
|
||||||
|
Err(RelicarioError::Decrypt) => {}
|
||||||
|
other => panic!("expected Decrypt for tampered tag, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
276
crates/relicario-core/tests/import_lastpass.rs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//! LastPass CSV importer — parser coverage.
|
||||||
|
|
||||||
|
use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
use relicario_core::item_types::{TotpAlgorithm, TotpKind};
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
|
||||||
|
const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_login_row_round_trips() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://github.com/login,alice,hunter2,,,GitHub,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 1, "one item expected");
|
||||||
|
assert!(warnings.is_empty(), "no warnings expected");
|
||||||
|
|
||||||
|
let item = &items[0];
|
||||||
|
assert_eq!(item.title, "GitHub");
|
||||||
|
assert!(!item.favorite);
|
||||||
|
assert!(item.group.is_none());
|
||||||
|
match &item.core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
assert_eq!(l.username.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(l.password.as_deref().map(String::as_str), Some("hunter2"));
|
||||||
|
assert_eq!(l.url.as_ref().map(|u| u.as_str()), Some("https://github.com/login"));
|
||||||
|
assert!(l.totp.is_none());
|
||||||
|
}
|
||||||
|
other => panic!("expected Login, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_id_is_freshly_minted() {
|
||||||
|
// Decision D12: title collisions don't dedupe; each row gets a fresh ID.
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Same,,\nhttps://x,u,p,,,Same,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_ne!(items[0].id, items[1].id, "IDs must be unique even for identical names");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertion helper used by later tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn first_warning_message(warnings: &[ImportWarning]) -> String {
|
||||||
|
warnings.first().expect("expected at least one warning").message.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grouping_maps_to_item_group() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,Finance,");
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert_eq!(items[0].group.as_deref(), Some("Finance"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_grouping_yields_none() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(items[0].group.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fav_one_marks_favorite() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,1");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(items[0].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fav_zero_or_blank_not_favorite() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,,,Zero,,0\n\
|
||||||
|
https://x,u,p,,,Blank,,",
|
||||||
|
);
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert!(!items[0].favorite);
|
||||||
|
assert!(!items[1].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_becomes_notes_for_login() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,a hint,Bank,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items[0].notes.as_deref(), Some("a hint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiline_extra_round_trips_via_quoting() {
|
||||||
|
// CSV double-quotes escape embedded newlines.
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,,\"line1\nline2\nline3\",Bank,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty(), "multi-line extra should parse cleanly");
|
||||||
|
assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_valid_totp_secret_attaches_config() {
|
||||||
|
// RFC 4648 base32 of b"12345678901234567890" → "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://github.com/login,alice,hunter2,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,,GitHub,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
let totp = l.totp.as_ref().expect("expected TOTP config");
|
||||||
|
assert_eq!(totp.algorithm, TotpAlgorithm::Sha1);
|
||||||
|
assert_eq!(totp.digits, 6);
|
||||||
|
assert_eq!(totp.period_seconds, 30);
|
||||||
|
assert_eq!(totp.kind, TotpKind::Totp);
|
||||||
|
assert_eq!(totp.secret.as_slice(), b"12345678901234567890");
|
||||||
|
}
|
||||||
|
other => panic!("expected Login, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_bad_totp_secret_imports_without_totp_and_warns() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://github.com/login,alice,hunter2,!!!!not-base32!!!!,,GitHub,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 1, "login should still import");
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => assert!(l.totp.is_none(), "TOTP must be dropped"),
|
||||||
|
other => panic!("expected Login, got {:?}", other),
|
||||||
|
}
|
||||||
|
assert_eq!(warnings.len(), 1);
|
||||||
|
let w = &warnings[0];
|
||||||
|
assert_eq!(w.title.as_deref(), Some("GitHub"));
|
||||||
|
assert!(w.message.contains("TOTP"), "message: {}", w.message);
|
||||||
|
assert!(w.message.contains("invalid") || w.message.contains("base32"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_lowercase_base32_totp_is_accepted() {
|
||||||
|
// RFC 4648 is case-insensitive; LastPass exports may use either case.
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,gezdgnbvgy3tqojqgezdgnbvgy3tqojq,,Acme,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty(), "lowercase base32 must parse");
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => assert!(l.totp.is_some()),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_http_sn_maps_to_secure_note() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
http://sn,,,,The body of the note,My Note,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].title, "My Note");
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), "The body of the note"),
|
||||||
|
other => panic!("expected SecureNote, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_note_does_not_require_password() {
|
||||||
|
// SecureNote rows have empty password; that must not trigger the
|
||||||
|
// `missing password` skip path (which is Login-only).
|
||||||
|
let csv = format!("{HEADER}\nhttp://sn,,,,note text,Title,,");
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty(), "{:?}", warnings);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_note_passes_through_grouping_and_favorite() {
|
||||||
|
let csv = format!("{HEADER}\nhttp://sn,,,,body,Title,Personal,1");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items[0].group.as_deref(), Some("Personal"));
|
||||||
|
assert!(items[0].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_note_preserves_structured_extra_verbatim() {
|
||||||
|
// LastPass packs structured note data (e.g. credit cards) into `extra`
|
||||||
|
// using their own key:value format. We do NOT auto-parse it — verbatim
|
||||||
|
// pass-through, per spec D10.
|
||||||
|
let csv_body = "NoteType:Credit Card\nNumber:4111111111111111\nCVV:123";
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
http://sn,,,,\"{csv_body}\",Visa,,",
|
||||||
|
csv_body = csv_body,
|
||||||
|
);
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), csv_body),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_unparseable_url_imports_with_url_none_and_warns() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
not-a-real-url,alice,hunter2,,,Site,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => assert!(l.url.is_none()),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
assert_eq!(warnings.len(), 1);
|
||||||
|
assert!(warnings[0].message.contains("URL"), "msg: {}", warnings[0].message);
|
||||||
|
assert_eq!(warnings[0].title.as_deref(), Some("Site"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_with_extra_column_is_rejected() {
|
||||||
|
let bad = "url,username,password,totp,extra,name,grouping,fav,EXTRA\nhttps://x,u,p,,,T,,";
|
||||||
|
let err = parse_lastpass_csv(bad.as_bytes()).unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(msg.contains("LastPass") || msg.contains("expected"), "msg: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_with_wrong_column_order_is_rejected() {
|
||||||
|
let swapped = "name,url,username,password,totp,extra,grouping,fav\nT,https://x,u,p,,,,";
|
||||||
|
let err = parse_lastpass_csv(swapped.as_bytes()).unwrap_err();
|
||||||
|
assert!(format!("{err}").contains("expected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quoted_comma_in_extra_parses() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,,\"hint with, a comma\",Site,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert_eq!(items[0].notes.as_deref(), Some("hint with, a comma"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_title_round_trips() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Müllerstraße — café ☕,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items[0].title, "Müllerstraße — café ☕");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_csv_after_header_returns_empty_vecs() {
|
||||||
|
let (items, warnings) = parse_lastpass_csv(HEADER.as_bytes()).unwrap();
|
||||||
|
assert!(items.is_empty());
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_header_is_rejected() {
|
||||||
|
// Empty input — csv reader treats first row as header (which doesn't exist).
|
||||||
|
let err = parse_lastpass_csv(b"").unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
// Either ImportCsvHeader (header didn't match) or ImportCsvFormat (read
|
||||||
|
// failed). Both are acceptable; we just need a clear error.
|
||||||
|
assert!(msg.contains("LastPass") || msg.contains("CSV"), "msg: {msg}");
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "relicario-wasm"
|
name = "relicario-wasm"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "WASM bindings for relicario password manager"
|
description = "WASM bindings for relicario password manager"
|
||||||
|
|
||||||
@@ -15,6 +15,10 @@ serde_json = "1"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
getrandom = { version = "0.2", features = ["js"] }
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3"
|
wasm-bindgen-test = "0.3"
|
||||||
|
|||||||
@@ -120,6 +120,16 @@ pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<V
|
|||||||
.map_err(|e| JsError::new(&e.to_string()))
|
.map_err(|e| JsError::new(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the JSON for `VaultSettings::default()`. Used by the setup
|
||||||
|
/// wizard to encrypt and write a default settings.enc on new-vault setup.
|
||||||
|
/// Keeping this in WASM (instead of hand-encoding in TS) prevents drift
|
||||||
|
/// when the default VaultSettings shape changes in Rust.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn default_vault_settings_json() -> Result<String, JsError> {
|
||||||
|
let s = VaultSettings::default();
|
||||||
|
serde_json::to_string(&s).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
|
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
|
||||||
|
|
||||||
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
|
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
|
||||||
@@ -196,6 +206,64 @@ pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
/// Generate an ed25519 keypair for device registration.
|
||||||
|
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn generate_device_keypair() -> Result<JsValue, JsError> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let signing_key = SigningKey::generate(&mut rng);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
|
||||||
|
let public_hex = hex::encode(verifying_key.as_bytes());
|
||||||
|
let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes());
|
||||||
|
|
||||||
|
js_value_for(&serde_json::json!({
|
||||||
|
"public_key_hex": public_hex,
|
||||||
|
"private_key_base64": private_b64,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract field history from a decrypted item JSON.
|
||||||
|
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_field_history(item_json: &str) -> Result<JsValue, JsError> {
|
||||||
|
let item: Item = serde_json::from_str(item_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Only section fields are tracked in field_history (set_field_value operates on sections).
|
||||||
|
for section in &item.sections {
|
||||||
|
for field in §ion.fields {
|
||||||
|
if field.value.is_history_tracked() {
|
||||||
|
if let Some(entries) = item.field_history.get(&field.id) {
|
||||||
|
if !entries.is_empty() {
|
||||||
|
let current = match &field.value {
|
||||||
|
relicario_core::FieldValue::Password(v) => v.as_str().to_owned(),
|
||||||
|
relicario_core::FieldValue::Concealed(v) => v.as_str().to_owned(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
results.push(serde_json::json!({
|
||||||
|
"field_id": field.id.as_str(),
|
||||||
|
"field_name": &field.label,
|
||||||
|
"current_value": current,
|
||||||
|
"entries": entries.iter().map(|e| serde_json::json!({
|
||||||
|
"value": e.value.as_str(),
|
||||||
|
"changed_at": e.replaced_at,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
js_value_for(&results)
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
|
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()))?;
|
let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
@@ -237,6 +305,155 @@ pub fn totp_compute(
|
|||||||
Ok(TotpCode { code, expires_at })
|
Ok(TotpCode { code, expires_at })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Backup container bridge ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use relicario_core::backup::{
|
||||||
|
pack_backup as core_pack_backup,
|
||||||
|
unpack_backup as core_unpack_backup,
|
||||||
|
BackupInput, BackupItem, BackupAttachment,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Pack a vault into a `.relbak` byte vector.
|
||||||
|
///
|
||||||
|
/// `input_json` shape:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "salt": "<base64>",
|
||||||
|
/// "params_json": "...",
|
||||||
|
/// "devices_json": "...",
|
||||||
|
/// "manifest_enc": "<base64>",
|
||||||
|
/// "settings_enc": "<base64>",
|
||||||
|
/// "items": [{"id": "<hex>", "ciphertext": "<base64>"}, ...],
|
||||||
|
/// "attachments": [{"item_id": "<hex>", "attachment_id": "<hex>", "ciphertext": "<base64>"}, ...],
|
||||||
|
/// "reference_jpg": "<base64>" | null,
|
||||||
|
/// "git_archive": "<base64>" | null
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result<Vec<u8>, JsError> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct InJson {
|
||||||
|
salt: String,
|
||||||
|
params_json: String,
|
||||||
|
devices_json: String,
|
||||||
|
manifest_enc: String,
|
||||||
|
settings_enc: String,
|
||||||
|
items: Vec<InItem>,
|
||||||
|
attachments: Vec<InAttachment>,
|
||||||
|
reference_jpg: Option<String>,
|
||||||
|
git_archive: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct InItem { id: String, ciphertext: String }
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct InAttachment { item_id: String, attachment_id: String, ciphertext: String }
|
||||||
|
|
||||||
|
let parsed: InJson = serde_json::from_str(input_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("backup input: {e}")))?;
|
||||||
|
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let salt = b64.decode(&parsed.salt).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let manifest = b64.decode(&parsed.manifest_enc).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let settings = b64.decode(&parsed.settings_enc).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let items_bytes: Vec<(String, Vec<u8>)> = parsed.items.iter()
|
||||||
|
.map(|i| {
|
||||||
|
let ct = b64.decode(&i.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok((i.id.clone(), ct))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, JsError>>()?;
|
||||||
|
let attach_bytes: Vec<(String, String, Vec<u8>)> = parsed.attachments.iter()
|
||||||
|
.map(|a| {
|
||||||
|
let ct = b64.decode(&a.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok((a.item_id.clone(), a.attachment_id.clone(), ct))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, JsError>>()?;
|
||||||
|
|
||||||
|
let ref_bytes = parsed.reference_jpg.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let git_bytes = parsed.git_archive.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
|
||||||
|
let items_refs: Vec<BackupItem> = items_bytes.iter()
|
||||||
|
.map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct })
|
||||||
|
.collect();
|
||||||
|
let attach_refs: Vec<BackupAttachment> = attach_bytes.iter()
|
||||||
|
.map(|(iid, aid, ct)| BackupAttachment {
|
||||||
|
item_id: iid.clone(),
|
||||||
|
attachment_id: aid.clone(),
|
||||||
|
ciphertext: ct,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let input = BackupInput {
|
||||||
|
salt: &salt,
|
||||||
|
params_json: &parsed.params_json,
|
||||||
|
devices_json: &parsed.devices_json,
|
||||||
|
manifest_enc: &manifest,
|
||||||
|
settings_enc: &settings,
|
||||||
|
items: items_refs,
|
||||||
|
attachments: attach_refs,
|
||||||
|
reference_jpg: ref_bytes.as_deref(),
|
||||||
|
git_archive: git_bytes.as_deref(),
|
||||||
|
};
|
||||||
|
core_pack_backup(input, passphrase).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack `.relbak` bytes; returns the JSON shape that mirrors `BackupOutput`,
|
||||||
|
/// with binary fields base64-encoded.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result<String, JsError> {
|
||||||
|
let out = core_unpack_backup(bytes, passphrase)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"salt": b64.encode(out.salt),
|
||||||
|
"params_json": out.params_json,
|
||||||
|
"devices_json": out.devices_json,
|
||||||
|
"manifest_enc": b64.encode(&out.manifest_enc),
|
||||||
|
"settings_enc": b64.encode(&out.settings_enc),
|
||||||
|
"items": out.items.iter().map(|i| serde_json::json!({
|
||||||
|
"id": i.id,
|
||||||
|
"ciphertext": b64.encode(&i.ciphertext),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"attachments": out.attachments.iter().map(|a| serde_json::json!({
|
||||||
|
"item_id": a.item_id,
|
||||||
|
"attachment_id": a.attachment_id,
|
||||||
|
"ciphertext": b64.encode(&a.ciphertext),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"reference_jpg": out.reference_jpg.as_ref().map(|b| b64.encode(b)),
|
||||||
|
"git_archive": out.git_archive.as_ref().map(|b| b64.encode(b)),
|
||||||
|
"created_at": out.created_at,
|
||||||
|
});
|
||||||
|
Ok(json.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LastPass CSV importer bridge ────────────────────────────────────────────
|
||||||
|
|
||||||
|
use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv;
|
||||||
|
|
||||||
|
/// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`.
|
||||||
|
///
|
||||||
|
/// Items are returned as full `Item` JSON objects with freshly-minted IDs
|
||||||
|
/// and timestamps already populated. The SW caller is responsible for
|
||||||
|
/// encrypting + writing them; this bridge stays pure so the preview UI
|
||||||
|
/// can render counts without committing anything.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
|
||||||
|
let (items, warnings) = core_parse_lastpass_csv(csv_bytes)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"items": items,
|
||||||
|
"warnings": warnings,
|
||||||
|
});
|
||||||
|
Ok(json.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod session_tests {
|
mod session_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -279,4 +496,31 @@ mod session_tests {
|
|||||||
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||||
assert_ne!(bytes, bytes2, "nonces must differ");
|
assert_ne!(bytes, bytes2, "nonces must differ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_lastpass_csv_json_returns_items_and_warnings() {
|
||||||
|
// Row 1 imports cleanly; row 2 has an empty `name` and is skipped
|
||||||
|
// with a warning.
|
||||||
|
let csv = "url,username,password,totp,extra,name,grouping,fav\n\
|
||||||
|
https://x,alice,hunter2,,,GitHub,Work,1\n\
|
||||||
|
https://y,bob,hunter2,,,,,";
|
||||||
|
let json = super::parse_lastpass_csv_json(csv.as_bytes()).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(v["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["warnings"].as_array().unwrap().len(), 1);
|
||||||
|
assert!(v["warnings"][0]["message"].as_str().unwrap().contains("name"));
|
||||||
|
// The item's title round-trips as a plain JSON string.
|
||||||
|
assert_eq!(v["items"][0]["title"].as_str().unwrap(), "GitHub");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_lastpass_csv_json_propagates_header_errors() {
|
||||||
|
// Test the underlying core function directly since native tests
|
||||||
|
// can't call wasm_bindgen functions.
|
||||||
|
use relicario_core::import_lastpass::parse_lastpass_csv;
|
||||||
|
let bad = "name,user,pass\nA,u,p\n";
|
||||||
|
let err = parse_lastpass_csv(bad.as_bytes());
|
||||||
|
// Should fail with a header validation error.
|
||||||
|
assert!(err.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario — Architecture
|
# Relicario — Architecture
|
||||||
|
|
||||||
## System Overview
|
## System Overview
|
||||||
|
|
||||||
|
|||||||
207
docs/architecture/overview.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Architecture overview — Relicario
|
||||||
|
|
||||||
|
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
||||||
|
|
||||||
|
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
|
||||||
|
>
|
||||||
|
> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md)
|
||||||
|
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md)
|
||||||
|
> - [extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md)
|
||||||
|
>
|
||||||
|
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
||||||
|
|
||||||
|
## The three codebases
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ relicario-core │
|
||||||
|
│ (Rust, no I/O) │
|
||||||
|
│ crypto · items │
|
||||||
|
│ manifest · stego │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼─────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌────────────────────┐ (compiled to WASM
|
||||||
|
│ relicario-cli │ │ relicario-wasm │ inside the )
|
||||||
|
│ (Rust binary) │ │ (#[wasm_bindgen] │ extension │
|
||||||
|
│ │ │ bindings) │ │
|
||||||
|
│ filesystem + │ │ │ │
|
||||||
|
│ git + │ └────────┬───────────┘ │
|
||||||
|
│ clap UX │ │ │
|
||||||
|
└────────────────┘ ▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ extension │ │
|
||||||
|
│ (TypeScript) │ │
|
||||||
|
│ popup · vault │ │
|
||||||
|
│ setup · content │ │
|
||||||
|
│ service worker │ │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Codebase | Language | Role | Key boundary |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
|
||||||
|
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
|
||||||
|
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
|
||||||
|
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
|
||||||
|
|
||||||
|
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow.
|
||||||
|
|
||||||
|
## Inter-codebase contracts
|
||||||
|
|
||||||
|
There are four boundaries where the codebases agree on a wire format. Each is versioned independently.
|
||||||
|
|
||||||
|
### 1. Core → WASM ABI (Rust / JS edge)
|
||||||
|
|
||||||
|
The `relicario-wasm` crate is the JS/Rust contract. Every WASM export takes `JsValue` / `&[u8]` / `&str` and returns the same. Strings on the wire are JSON-encoded for any structured data; raw bytes for ciphertext / images / attachments.
|
||||||
|
|
||||||
|
Adding a new core capability for the extension requires:
|
||||||
|
1. Add the capability to `relicario-core/src/`.
|
||||||
|
2. Re-export through `lib.rs`.
|
||||||
|
3. Add a thin `#[wasm_bindgen]` wrapper to `relicario-wasm/src/lib.rs`.
|
||||||
|
4. Run `wasm-pack build` (via `npm run build:wasm` in `extension/`).
|
||||||
|
5. Use it from the extension's service worker (or setup wizard).
|
||||||
|
|
||||||
|
The `SessionHandle` is the cross-language opaque token: WASM owns the `Zeroizing<[u8;32]>` master key behind a numeric handle; JS only ever holds the number. JS calling `wasm.lock(handle)` zeroes the WASM-side memory and invalidates the handle.
|
||||||
|
|
||||||
|
### 2. Service worker ↔ popup / vault tab / content script (chrome.runtime messages)
|
||||||
|
|
||||||
|
All extension bundles other than the SW communicate with the SW exclusively via `chrome.runtime.sendMessage`. The protocol is defined in `extension/src/shared/messages.ts`:
|
||||||
|
|
||||||
|
- `PopupMessage` — sent by popup, vault tab, or setup wizard
|
||||||
|
- `ContentMessage` — sent by content scripts injected into web pages
|
||||||
|
- `Response` — returned by the SW: `{ ok: true, data?: ... } | { ok: false, error: string }`
|
||||||
|
|
||||||
|
Two **capability sets** in `messages.ts` gate which sender can issue which message:
|
||||||
|
|
||||||
|
- `POPUP_ONLY_TYPES` — accepted only from popup.html, vault.html, or setup.html
|
||||||
|
- `CONTENT_CALLABLE_TYPES` — accepted only from content scripts
|
||||||
|
|
||||||
|
The router (`service-worker/router/index.ts`) dispatches by sender. Adding a new message type requires adding it to one of the capability sets, **or it is silently rejected**. Vault tab parity (commit `a7dbf35`) is implemented by recognizing `vault.html` as a popup-class sender at the router level.
|
||||||
|
|
||||||
|
### 3. Vault on disk (shared by CLI and extension)
|
||||||
|
|
||||||
|
Every relicario vault — whether on disk for the CLI or in a git remote read by the extension — has the same layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault root>/
|
||||||
|
├── .relicario/
|
||||||
|
│ ├── salt # 32 bytes, random per vault, stays constant
|
||||||
|
│ ├── params.json # KdfParams: argon2_m, argon2_t, argon2_p
|
||||||
|
│ └── devices.json # [{ name, public_key }, ...]
|
||||||
|
├── manifest.enc # encrypted Manifest (browse-without-decrypt index)
|
||||||
|
├── settings.enc # encrypted VaultSettings
|
||||||
|
├── items/
|
||||||
|
│ └── <id>.enc # encrypted Item, one per file
|
||||||
|
└── attachments/
|
||||||
|
└── <item-id>/
|
||||||
|
└── <aid>.enc # encrypted attachment blob; aid is content-addressed SHA-256
|
||||||
|
```
|
||||||
|
|
||||||
|
The reference image (`reference.jpg`) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git.
|
||||||
|
|
||||||
|
This layout is not formally versioned — the **content** within each `.enc` file carries its own version byte (see § Versioning below). The directory layout itself is conventional and changes would be breaking.
|
||||||
|
|
||||||
|
### 4. Git remote API (extension's `GitHost`)
|
||||||
|
|
||||||
|
The extension cannot shell out to `git`; it talks to the remote via the host's REST API. Two implementations live in `extension/src/service-worker/`:
|
||||||
|
|
||||||
|
- `gitea.ts` — Gitea / Forgejo API
|
||||||
|
- `github.ts` — GitHub API
|
||||||
|
|
||||||
|
Both implement the `GitHost` interface in `git-host.ts`. Adding a third host (GitLab, Bitbucket, custom) means implementing that interface — the rest of the extension is host-agnostic.
|
||||||
|
|
||||||
|
The CLI does not use `GitHost`; it shells out to `git` directly via the hardened wrapper in `relicario-cli/src/helpers.rs:46`.
|
||||||
|
|
||||||
|
## Versioning strategy
|
||||||
|
|
||||||
|
There is no single "relicario format version." Each piece of the format is versioned independently so we can evolve without coordinated upgrades.
|
||||||
|
|
||||||
|
| Artifact | Where versioned | Current value | Failure mode on read |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AEAD ciphertext | First byte of every `.enc` blob | `VERSION_BYTE = 0x02` (in `relicario-core/src/crypto.rs`) | `RelicarioError::Format` — refuses to attempt decryption |
|
||||||
|
| Manifest schema | `Manifest.schema_version` field | `2` (set in `relicario-core/src/manifest.rs`) | v1 manifests are explicitly rejected with a clear error |
|
||||||
|
| KDF parameters | `.relicario/params.json` | Vault-specific (initially m=64MiB, t=3, p=4) | Read at unlock; stored alongside the vault |
|
||||||
|
| Backup container | First 5 bytes of `.relbak`: magic `"RBAK"` + version byte | `0x01` (designed; see import/export spec) | Format-version error if newer-version backup is read by older binary |
|
||||||
|
| Device entry | `devices.json` array of `{ name, public_key }` | Unversioned (extend by adding optional fields) | — |
|
||||||
|
|
||||||
|
The intentional design: **no big-bang upgrades**. A user can run an older CLI against a newer vault as long as the AEAD version, manifest schema, and KDF params are still compatible.
|
||||||
|
|
||||||
|
## Where secrets live
|
||||||
|
|
||||||
|
The threat model differs by codebase. This is the per-secret per-codebase residence map:
|
||||||
|
|
||||||
|
| Secret | relicario-core | relicario-cli | extension SW | extension popup/vault/content/setup |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Passphrase (UTF-8 bytes) | `Zeroizing<String>` only during a single `derive_master_key` call | Same, in `UnlockedVault::unlock_interactive` | Same, used briefly to derive master key inside WASM | Never seen — entered into a `<input type="password">`, sent to SW via `unlock` message, immediately forgotten |
|
||||||
|
| Reference image bytes | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to extract the secret | Same | Setup wizard holds the bytes briefly during create/attach modes |
|
||||||
|
| Image secret (32 B) | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it |
|
||||||
|
| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it |
|
||||||
|
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes |
|
||||||
|
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
|
||||||
|
|
||||||
|
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything.
|
||||||
|
|
||||||
|
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op.
|
||||||
|
|
||||||
|
## Build matrix
|
||||||
|
|
||||||
|
| Target | Tool | Output | When to run |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
|
||||||
|
| Native test suites | `cargo test` (workspace) | — | After any Rust change |
|
||||||
|
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
|
||||||
|
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
|
||||||
|
| Firefox extension | `webpack --config webpack.firefox.config.js` (`npm run build:firefox`) | `extension/dist-firefox/` | After TS or WASM changes; for Firefox distribution |
|
||||||
|
| All extension targets | `npm run build:all` | Both `dist/` and `dist-firefox/` plus rebuilt WASM | Pre-release |
|
||||||
|
| Extension tests | `npm test` (vitest, happy-dom) | — | After TS changes |
|
||||||
|
|
||||||
|
The WASM build sequence matters: `wasm-pack` writes the binary into `extension/wasm/` before `webpack` picks it up. `npm run build:all` runs them in order. Manual builds need the same order.
|
||||||
|
|
||||||
|
## Test strategy at the workspace level
|
||||||
|
|
||||||
|
| Layer | Tool | Where | What it covers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Core unit tests | `cargo test -p relicario-core` | `crates/relicario-core/src/**/#[cfg(test)]` and `tests/*.rs` | Crypto round-trip, item serialization, manifest schema, generators, imgsecret embed/extract, format-v2 parsing |
|
||||||
|
| CLI integration tests | `cargo test -p relicario-cli` | `crates/relicario-cli/tests/*.rs` | End-to-end via `TestVault::init()` harness with synthetic JPEGs and `RELICARIO_TEST_*` env-var escape hatches; covers basic flows, edit + history (incl. TOTP), attachments, settings, vault detection |
|
||||||
|
| Extension unit tests | `npm test` (vitest) | `extension/src/**/__tests__/*.test.ts` | Component render + click handlers (mocked SW), router sender dispatch, SW handler logic (mocked WASM + chrome.storage) |
|
||||||
|
| End-to-end | none | — | No real-browser tests; mocks stand in. Build-vs-test gap is documented in extension/ARCHITECTURE.md |
|
||||||
|
|
||||||
|
Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take forever; the production path is the same code with real params. The CLI's `init` command always uses production-grade params even under tests.
|
||||||
|
|
||||||
|
## Conventions that span all three codebases
|
||||||
|
|
||||||
|
| Rule | Where enforced | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Master key only in `Zeroizing<[u8;32]>` | core types; CLI follows; extension WASM follows | Drop-on-scope-exit zeroization; never leaves stack |
|
||||||
|
| AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly |
|
||||||
|
| Item IDs are random 8-char hex | `core/ids.rs` | Stable, short, no information leak |
|
||||||
|
| Attachment IDs are content-addressed (SHA-256) | `core/ids.rs` | Dedup; integrity check |
|
||||||
|
| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || image_secret` collisions |
|
||||||
|
| Git history is preserved as audit log; never squash | CLI commits; SW commits | Per-action history is a feature |
|
||||||
|
| Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log |
|
||||||
|
| Hardened git invocations (`-c core.hooksPath=/dev/null` etc.) | CLI's `helpers::git_command`; SW does not shell out | Prevent hostile hooks; no GPG prompt holding key alive |
|
||||||
|
| Atomic writes (write `.tmp` → rename) | CLI's `session::atomic_write`; SW's vault.ts equivalents | Partial-write safety |
|
||||||
|
| Tests use synthesized JPEGs (`make_test_jpeg`), not committed binaries | Both Rust and TS test harnesses | Repo stays small; reproducible |
|
||||||
|
| Test-only env vars (`RELICARIO_TEST_*`) have no production fall-through | Verified in `relicario-cli` audit | Escape hatches don't leak into builds |
|
||||||
|
|
||||||
|
## Where to look next
|
||||||
|
|
||||||
|
| If you're working on... | Start with |
|
||||||
|
|---|---|
|
||||||
|
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/ARCHITECTURE.md) |
|
||||||
|
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) |
|
||||||
|
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
|
||||||
|
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
|
||||||
|
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
|
||||||
|
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
|
||||||
|
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
|
||||||
|
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
|
||||||
|
| Running the full test suite | `cargo test && (cd extension && npm test)` |
|
||||||
|
| Bumping the WASM module after a core change | `cd extension && npm run build:wasm` |
|
||||||
|
|
||||||
|
## Stale spec docs
|
||||||
|
|
||||||
|
The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts).
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario Security Audit Report
|
# Relicario Security Audit Report
|
||||||
|
|
||||||
**Date:** 2026-04-18
|
**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`.
|
**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`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario 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.
|
> **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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario Credential Capture Implementation Plan
|
# 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.
|
> **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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario Firefox Extension Port Implementation Plan
|
# 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.
|
> **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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# relicario Vault Initialization Wizard Implementation Plan
|
# 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.
|
> **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.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario WASM + Chrome MV3 Extension Implementation Plan
|
# Relicario WASM + Chrome MV3 Extension 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.
|
> **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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario Extension 1C-α (Foundation) Implementation Plan
|
# Relicario Extension 1C-α (Foundation) 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.
|
> **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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# relicario Extension 1C-β₁ (Typed-Item Forms) Implementation Plan
|
# Relicario Extension 1C-β₁ (Typed-Item Forms) 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.
|
> **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 the 5 remaining typed-item forms (SecureNote, Identity, Card, Key, Totp incl. Steam Guard) so the relicario extension can daily-drive every typed item the Rust core supports except Document.
|
**Goal:** Add the 5 remaining typed-item forms (SecureNote, Identity, Card, Key, Totp incl. Steam Guard) so the Relicario extension can daily-drive every typed item the Rust core supports except Document.
|
||||||
|
|
||||||
**Architecture:** 5-slice bottom-up sequencing. Slice 1 patches the Rust core's `compute_totp_code` to emit Steam's 5-char alphabet output. Slice 2 extracts a shared `popup/components/fields.ts` helper module (row / concealed-row / signature-block primitives) and refactors Login onto it as the reference implementation. Slices 3-5 land the 5 new types in pairs: SecureNote+Identity (no signature block), Card+Key (signature block, no live state), Totp (signature block + countdown + Steam toggle).
|
**Architecture:** 5-slice bottom-up sequencing. Slice 1 patches the Rust core's `compute_totp_code` to emit Steam's 5-char alphabet output. Slice 2 extracts a shared `popup/components/fields.ts` helper module (row / concealed-row / signature-block primitives) and refactors Login onto it as the reference implementation. Slices 3-5 land the 5 new types in pairs: SecureNote+Identity (no signature block), Card+Key (signature block, no live state), Totp (signature block + countdown + Steam toggle).
|
||||||
|
|
||||||
|
|||||||
2650
docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md
Normal file
2118
docs/superpowers/plans/2026-04-24-relicario-extension-1c-gamma1.md
Normal file
908
docs/superpowers/plans/2026-04-24-relicario-gen-ux-redesign.md
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
# Generator UX Redesign 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:** Replace the right-anchored popover (which clips off the popup edge) with an inline panel injected into the form below the password row. Trigger becomes ✨; lowercase form labels with a gold required-marker.
|
||||||
|
|
||||||
|
**Architecture:** The popover module gets renamed (`generator-popover.ts` → `generator-panel.ts`) and rewritten: same knob → message logic, but DOM mounts inside a passed parent element instead of `document.body`, and the action row varies by context (`fill-field` for the login form's password input, `configure-defaults` for the vault settings screen). Label polish is a single CSS rule update plus an `<span class="req">` wrap around the `*` markers in 6 type forms.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, vitest, webpack, plain CSS (no preprocessor).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-24-relicario-gen-ux-redesign-design.md` (commit `9add305`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Label polish — lowercase + gold required marker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/styles.css` (the `.label` rule + add `.req` rule)
|
||||||
|
- Modify: `extension/src/popup/components/types/login.ts` (1 markup change at line ~234)
|
||||||
|
- Modify: `extension/src/popup/components/types/identity.ts` (1 markup change at line ~129)
|
||||||
|
- Modify: `extension/src/popup/components/types/card.ts` (1 markup change at line ~169)
|
||||||
|
- Modify: `extension/src/popup/components/types/key.ts` (2 markup changes at lines ~118, ~120)
|
||||||
|
- Modify: `extension/src/popup/components/types/totp.ts` (2 markup changes at lines ~208, ~217)
|
||||||
|
- Modify: `extension/src/popup/components/types/secure-note.ts` (1 markup change at line ~107)
|
||||||
|
|
||||||
|
Working dir: `/home/alee/Sources/relicario`. Branch: main. Do NOT push.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the `.label` rule**
|
||||||
|
|
||||||
|
In `extension/src/popup/styles.css`, find the `.label {` block (around line 36-45) and change `text-transform`, `letter-spacing`, and `font-weight`:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```css
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8b949e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```css
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #8b949e;
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `.req` rule for gold required-marker**
|
||||||
|
|
||||||
|
Append this rule directly after the `.label` rule (so it's adjacent and easy to find):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.label .req {
|
||||||
|
color: #aa812a;
|
||||||
|
margin-left: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update markup in all 6 type forms**
|
||||||
|
|
||||||
|
For each of the 7 occurrences of `title *</label>`, `key material *</label>`, `secret (base32) *</label>`, etc., replace the literal `*` with `<span class="req">*</span>`.
|
||||||
|
|
||||||
|
Run a sed sweep across the 6 type files (preserves all other content, swaps just the trailing `*</label>` pattern):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's| \*</label>| <span class="req">*</span></label>|g' \
|
||||||
|
extension/src/popup/components/types/login.ts \
|
||||||
|
extension/src/popup/components/types/identity.ts \
|
||||||
|
extension/src/popup/components/types/card.ts \
|
||||||
|
extension/src/popup/components/types/key.ts \
|
||||||
|
extension/src/popup/components/types/totp.ts \
|
||||||
|
extension/src/popup/components/types/secure-note.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the swap landed in every expected file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn '<span class="req">\*</span></label>' extension/src/popup/components/types/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 8 hits across 6 files (login×1, identity×1, card×1, key×2, totp×2, secure-note×1).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn ' \*</label>' extension/src/popup/components/types/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output (every literal `*</label>` should now be wrapped).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run vitest**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bun run test 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 124 passed (some test fixtures may render label HTML — verify they don't have hard-coded assertions on the literal `*` text or the `text-transform: uppercase` style. If any test fails on a label assertion, update the test to match the new markup).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Type-check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bunx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: zero errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git add extension/src/popup/styles.css \
|
||||||
|
extension/src/popup/components/types/login.ts \
|
||||||
|
extension/src/popup/components/types/identity.ts \
|
||||||
|
extension/src/popup/components/types/card.ts \
|
||||||
|
extension/src/popup/components/types/key.ts \
|
||||||
|
extension/src/popup/components/types/totp.ts \
|
||||||
|
extension/src/popup/components/types/secure-note.ts
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(ext/popup): lowercase form labels + gold required marker
|
||||||
|
|
||||||
|
.label drops text-transform: uppercase and tightens letter-spacing.
|
||||||
|
The `*` required marker gets wrapped in <span class="req"> so it
|
||||||
|
picks up the gold accent color (matches palette refresh).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Rename module — `generator-popover` → `generator-panel`
|
||||||
|
|
||||||
|
**Files (rename via git-mv):**
|
||||||
|
- Rename: `extension/src/popup/components/generator-popover.ts` → `generator-panel.ts`
|
||||||
|
- Rename: `extension/src/popup/components/__tests__/generator-popover.test.ts` → `generator-panel.test.ts`
|
||||||
|
|
||||||
|
**Files modified (import path update only — function names stay the same in this task):**
|
||||||
|
- `extension/src/popup/components/types/login.ts` (line 17 import)
|
||||||
|
- `extension/src/popup/components/settings-vault.ts` (line 9 import)
|
||||||
|
|
||||||
|
Working dir: `/home/alee/Sources/relicario`. Branch: main. Do NOT push.
|
||||||
|
|
||||||
|
- [ ] **Step 1: git-mv source + test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git mv extension/src/popup/components/generator-popover.ts \
|
||||||
|
extension/src/popup/components/generator-panel.ts
|
||||||
|
git mv extension/src/popup/components/__tests__/generator-popover.test.ts \
|
||||||
|
extension/src/popup/components/__tests__/generator-panel.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the test file's import path**
|
||||||
|
|
||||||
|
Edit `extension/src/popup/components/__tests__/generator-panel.test.ts` line 8:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
(Only the path string changes; function names stay untouched in this task.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update login.ts import path**
|
||||||
|
|
||||||
|
Edit `extension/src/popup/components/types/login.ts` line 17:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update settings-vault.ts import path**
|
||||||
|
|
||||||
|
Edit `extension/src/popup/components/settings-vault.ts` line 9:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover } from './generator-popover';
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover } from './generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify no stale references to `generator-popover` exist**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "generator-popover" extension/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no output (all imports updated).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run vitest**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bun run test 2>&1 | tail -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 124 passed (no behavioral change — just file rename).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Type-check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bunx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: zero errors.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git add extension/src/popup/components/generator-panel.ts \
|
||||||
|
extension/src/popup/components/generator-popover.ts \
|
||||||
|
extension/src/popup/components/__tests__/generator-panel.test.ts \
|
||||||
|
extension/src/popup/components/__tests__/generator-popover.test.ts \
|
||||||
|
extension/src/popup/components/types/login.ts \
|
||||||
|
extension/src/popup/components/settings-vault.ts
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
refactor(ext/popup): rename generator-popover module to generator-panel
|
||||||
|
|
||||||
|
Pure rename via git-mv (preserves history). Function names and behavior
|
||||||
|
unchanged. Sets up the API rewrite in the next commit.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Rewrite panel module + new CSS + caller updates + new tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/components/generator-panel.ts` (major rewrite — new API, inline mount, escape handler)
|
||||||
|
- Modify: `extension/src/popup/components/__tests__/generator-panel.test.ts` (function rename + parent mount + 3 new tests)
|
||||||
|
- Modify: `extension/src/popup/styles.css` (delete `.generator-popover` rules; add `.gen-trigger` + `.gen-panel` rules)
|
||||||
|
- Modify: `extension/src/popup/components/types/login.ts` (✨ trigger button + new openGeneratorPanel call with `context: 'fill-field'`)
|
||||||
|
- Modify: `extension/src/popup/components/settings-vault.ts` (✨ trigger button + new openGeneratorPanel call with `context: 'configure-defaults'`)
|
||||||
|
|
||||||
|
Working dir: `/home/alee/Sources/relicario`. Branch: main. Do NOT push.
|
||||||
|
|
||||||
|
This is the largest task. Steps walk through each file.
|
||||||
|
|
||||||
|
### Step 1: Rewrite `generator-panel.ts`
|
||||||
|
|
||||||
|
Read the current file first (Read tool) to understand the existing helper functions (`knobsFromRequest`, `requestFromKnobs`, `buildInnerHtml`, `wireInner`, `updateValidation`). KEEP those helpers AS-IS — they encode the knob→GeneratorRequest mapping which is correct. The rewrite only changes:
|
||||||
|
|
||||||
|
1. Function rename: `openGeneratorPopover` → `openGeneratorPanel`. Same for `closeGeneratorPopover` → `closeGeneratorPanel`.
|
||||||
|
2. New options interface (replaces `OpenPopoverOpts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type GeneratorPanelContext = 'fill-field' | 'configure-defaults';
|
||||||
|
|
||||||
|
export interface OpenPanelOpts {
|
||||||
|
parent: HTMLElement; // mount target (form root or settings section)
|
||||||
|
trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here)
|
||||||
|
initial: GeneratorRequest;
|
||||||
|
context: GeneratorPanelContext;
|
||||||
|
onPicked?: (value: string) => void; // required when context === 'fill-field'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The `host` div is appended to `opts.parent` instead of `document.body`. Drop the `position: absolute / top / left` styling — just `parent.appendChild(host)`.
|
||||||
|
4. The trigger gets `aria-expanded="true"` on open, `"false"` on close.
|
||||||
|
5. Escape key closes the panel. Add a `document.addEventListener('keydown', escHandler)` on open; remove on close. Handler:
|
||||||
|
```ts
|
||||||
|
const escHandler = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') closeGeneratorPanel();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
6. Auto-generate on open: call `render()` then immediately `refreshPreview()` (the existing render does this already in the current popover — confirm it still does in the rewrite).
|
||||||
|
7. Action row varies by context. Two HTML branches:
|
||||||
|
- `context === 'fill-field'`: `<button class="save-link" id="gen-save-default">↑ save these as default</button> <button class="btn" id="gen-cancel">cancel</button> <button class="btn btn-primary" id="gen-use">use</button>`
|
||||||
|
- `context === 'configure-defaults'`: `<button class="save-link" id="gen-save-default">↑ save these as default</button>` (no cancel/use)
|
||||||
|
8. Clicking ✨ while panel open should close it. The trigger's click handler in the caller (login.ts / settings-vault.ts) checks `if (isGeneratorPanelOpen()) closeGeneratorPanel(); else openGeneratorPanel(...)`. Add `export function isGeneratorPanelOpen(): boolean { return activePanel !== null; }`.
|
||||||
|
9. The "more ▾" disclosure: render only for `random` mode (BIP39 has no advanced knobs after the redesign). For `random`, advanced contains the `symbolCharset` toggle. Use `<details>` element for natural disclosure semantics:
|
||||||
|
```html
|
||||||
|
<details class="more">
|
||||||
|
<summary>more ▾</summary>
|
||||||
|
<div class="more__advanced">
|
||||||
|
<!-- knobs go here -->
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
```
|
||||||
|
10. Element IDs that the existing tests assert on MUST be preserved verbatim: `#gen-kind-random`, `#gen-kind-bip39`, `#gen-length`, `#gen-lower`, `#gen-upper`, `#gen-digits`, `#gen-symbols`, `#gen-use`, `#gen-save-default`. The HTML structure can change, but these IDs stay.
|
||||||
|
11. The `closeGeneratorPanel` function must clear:
|
||||||
|
- `activePanel = null`
|
||||||
|
- The `host` element from its parent (host.remove())
|
||||||
|
- `aria-expanded="false"` on the trigger
|
||||||
|
- `document.removeEventListener('keydown', escHandler)`
|
||||||
|
- Any pending debounce timer
|
||||||
|
|
||||||
|
The full new `openGeneratorPanel` skeleton (use this as the structure; fill in the helper-function calls from the existing module which you keep unchanged):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let activePanel: {
|
||||||
|
host: HTMLElement;
|
||||||
|
trigger: HTMLElement;
|
||||||
|
cleanup: () => void;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function openGeneratorPanel(opts: OpenPanelOpts): void {
|
||||||
|
closeGeneratorPanel();
|
||||||
|
|
||||||
|
const knobs = knobsFromRequest(opts.initial);
|
||||||
|
let currentPreview = '';
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'gen-panel';
|
||||||
|
opts.parent.appendChild(host);
|
||||||
|
|
||||||
|
opts.trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
const escHandler = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') closeGeneratorPanel();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
if (debounceTimer !== null) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = null;
|
||||||
|
}
|
||||||
|
opts.trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
host.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
activePanel = { host, trigger: opts.trigger, cleanup };
|
||||||
|
|
||||||
|
const render = (): void => {
|
||||||
|
host.innerHTML = buildInnerHtml(knobs, opts.context);
|
||||||
|
wireInner(opts);
|
||||||
|
refreshPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPreview = (): void => {
|
||||||
|
/* existing debounced refresh logic — copy from current module */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* wireInner needs `opts` for context (action row composition) and onPicked callback */
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGeneratorPanel(): void {
|
||||||
|
if (activePanel === null) return;
|
||||||
|
activePanel.cleanup();
|
||||||
|
activePanel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGeneratorPanelOpen(): boolean {
|
||||||
|
return activePanel !== null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `buildInnerHtml(knobs, context)` to:
|
||||||
|
- Use `<details class="more">` for the disclosure
|
||||||
|
- Render the action row based on `context`
|
||||||
|
- Use the new `.gen-panel` child class names (no more `.gen-row`, `.gen-row__label`, etc. — see new CSS in Step 2)
|
||||||
|
|
||||||
|
Keep `wireInner` as a closure-scoped helper inside `openGeneratorPanel` (NOT a parameter-taking function — it gets direct access to `opts`, `knobs`, `host`, `currentPreview` via the parent scope, just like the current popover does). Update its body to wire:
|
||||||
|
- `#gen-use` click → `opts.onPicked?.(currentPreview); closeGeneratorPanel();`
|
||||||
|
- `#gen-cancel` click → `closeGeneratorPanel();`
|
||||||
|
- `#gen-save-default` click → existing logic (fetch settings, update with new defaults, send `update_vault_settings`); on success append a `<span class="save-link__toast">✓ saved</span>` to the save-link button and remove it after 1500 ms via `setTimeout`. Skeleton:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
document.getElementById('gen-save-default')?.addEventListener('click', async () => {
|
||||||
|
const link = host.querySelector('#gen-save-default') as HTMLElement;
|
||||||
|
/* fetch settings, write generator_defaults, send update_vault_settings */
|
||||||
|
const settingsResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (!settingsResp.ok) return;
|
||||||
|
const settings = (settingsResp.data as { settings: VaultSettings }).settings;
|
||||||
|
settings.generator_defaults = requestFromKnobs(knobs);
|
||||||
|
const updateResp = await sendMessage({ type: 'update_vault_settings', settings });
|
||||||
|
if (!updateResp.ok) return;
|
||||||
|
/* append + auto-remove toast */
|
||||||
|
link.querySelector('.save-link__toast')?.remove();
|
||||||
|
const toast = document.createElement('span');
|
||||||
|
toast.className = 'save-link__toast';
|
||||||
|
toast.textContent = '✓ saved';
|
||||||
|
link.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 1500);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply this rewrite. The full file should still be ~250-350 lines; structure stays similar to the current popover.
|
||||||
|
|
||||||
|
### Step 2: Replace popover CSS with panel CSS in `styles.css`
|
||||||
|
|
||||||
|
Find the current `/* --- generator popover (β₂ slice 4) --- */` section (around line 592) and the `.gen-preview-line` rule below it. DELETE the entire block of `.generator-popover` rules (~80 lines).
|
||||||
|
|
||||||
|
Add this new block in the same location:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* --- generator panel (gen-UX redesign) --- */
|
||||||
|
|
||||||
|
.gen-trigger {
|
||||||
|
background: #7c5719;
|
||||||
|
color: #fff3cf;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.gen-trigger:hover { background: #aa812a; }
|
||||||
|
.gen-trigger[aria-expanded="true"] { background: #aa812a; }
|
||||||
|
|
||||||
|
.gen-panel {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #aa812a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 11px;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
.gen-panel .panel-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: #21262d;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.gen-panel .panel-toggle button {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #8b949e;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.gen-panel .panel-toggle button.active {
|
||||||
|
background: #aa812a;
|
||||||
|
color: #fff3cf;
|
||||||
|
}
|
||||||
|
.gen-panel .knob {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.gen-panel .knob__label {
|
||||||
|
color: #8b949e;
|
||||||
|
width: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.gen-panel .knob__slider { flex: 1; }
|
||||||
|
.gen-panel .knob__value {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
.gen-panel .classes {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
margin: 6px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
.gen-panel .classes label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.gen-panel .preview {
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.gen-panel .preview__value {
|
||||||
|
flex: 1;
|
||||||
|
color: #f1cf6e;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.gen-panel .preview__regen {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #8b949e;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.gen-panel .more {
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.gen-panel .more summary {
|
||||||
|
list-style: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.gen-panel .more summary::-webkit-details-marker { display: none; }
|
||||||
|
.gen-panel .more:hover { color: #d2ab43; }
|
||||||
|
.gen-panel .more__advanced { margin-top: 6px; }
|
||||||
|
.gen-panel .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.gen-panel .actions .save-link {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #8b949e;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: #30363d;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.gen-panel .actions .save-link:hover {
|
||||||
|
color: #d2ab43;
|
||||||
|
text-decoration-color: #d2ab43;
|
||||||
|
}
|
||||||
|
.gen-panel .actions .save-link__toast {
|
||||||
|
color: #3fb950;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* keep .gen-preview-line — it's the summary-text in vault settings, separate from panel */
|
||||||
|
```
|
||||||
|
|
||||||
|
The pre-existing `.gen-preview-line` rule (around line 674) must stay — it's used by the vault-settings summary text, not the panel itself.
|
||||||
|
|
||||||
|
### Step 3: Update `login.ts`
|
||||||
|
|
||||||
|
Find the `gen-btn` markup (around line 243):
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">✨</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the click handler (around line 268):
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
|
||||||
|
const anchor = e.currentTarget as HTMLElement;
|
||||||
|
const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST;
|
||||||
|
openGeneratorPopover({
|
||||||
|
anchor,
|
||||||
|
initial,
|
||||||
|
onPicked: (value) => {
|
||||||
|
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
||||||
|
if (pw) { pw.value = value; pw.type = 'text'; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
|
||||||
|
const trigger = e.currentTarget as HTMLElement;
|
||||||
|
if (isGeneratorPanelOpen()) {
|
||||||
|
closeGeneratorPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordRow = trigger.closest('.form-group') as HTMLElement | null;
|
||||||
|
if (!passwordRow) return;
|
||||||
|
const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST;
|
||||||
|
openGeneratorPanel({
|
||||||
|
parent: passwordRow, // panel mounts inside the password form-group
|
||||||
|
trigger,
|
||||||
|
initial,
|
||||||
|
context: 'fill-field',
|
||||||
|
onPicked: (value) => {
|
||||||
|
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
||||||
|
if (pw) { pw.value = value; pw.type = 'text'; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the import on line 17:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Update `settings-vault.ts`
|
||||||
|
|
||||||
|
Find the `configure-gen` button (around line 131):
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
<button class="btn" id="configure-gen">configure ▾</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the click handler (around line 196):
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
document.getElementById('configure-gen')?.addEventListener('click', (e) => {
|
||||||
|
/* current popover open with onPicked that writes to vault settings */
|
||||||
|
...
|
||||||
|
openGeneratorPopover({
|
||||||
|
anchor: e.currentTarget as HTMLElement,
|
||||||
|
initial: pendingSettings.generator_defaults,
|
||||||
|
/* ... onPicked writes to settings ... */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
document.getElementById('configure-gen')?.addEventListener('click', (e) => {
|
||||||
|
const trigger = e.currentTarget as HTMLElement;
|
||||||
|
if (isGeneratorPanelOpen()) {
|
||||||
|
closeGeneratorPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const generatorSection = trigger.closest('.settings-section') as HTMLElement | null;
|
||||||
|
if (!generatorSection || pendingSettings === null) return;
|
||||||
|
openGeneratorPanel({
|
||||||
|
parent: generatorSection,
|
||||||
|
trigger,
|
||||||
|
initial: pendingSettings.generator_defaults,
|
||||||
|
context: 'configure-defaults',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the import on line 9:
|
||||||
|
|
||||||
|
Old:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPopover } from './generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Update tests
|
||||||
|
|
||||||
|
In `extension/src/popup/components/__tests__/generator-panel.test.ts`, multiple changes:
|
||||||
|
|
||||||
|
1. Update import at line 8:
|
||||||
|
```ts
|
||||||
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update `setupAnchor()` to set up a parent + trigger in a way that matches the new API:
|
||||||
|
```ts
|
||||||
|
function setupMount(): { parent: HTMLElement; trigger: HTMLElement } {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="parent">
|
||||||
|
<button id="trigger" aria-expanded="false">✨</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
parent: document.getElementById('parent')!,
|
||||||
|
trigger: document.getElementById('trigger')!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update each test's `openGeneratorPopover({ anchor, ... })` to `openGeneratorPanel({ parent, trigger, context: 'fill-field', onPicked, ...})`. For the `save-as-default` test, use `context: 'fill-field'` (the save-link is shown in both contexts). For tests that don't care about onPicked, pass `vi.fn()`.
|
||||||
|
|
||||||
|
4. Update the selector `.generator-popover` → `.gen-panel` in tests that query for the panel host element (e.g., the "opens a popover" test asserts `document.querySelector('.generator-popover')` — change to `.gen-panel`).
|
||||||
|
|
||||||
|
5. Add 3 new tests at the end of the `describe` block:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('sets aria-expanded on the trigger when opened', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
closeGeneratorPanel();
|
||||||
|
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-generates a preview on open', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls.filter(
|
||||||
|
([msg]) => (msg as { type: string }).type === 'generate_password',
|
||||||
|
);
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape key closes the panel', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(isGeneratorPanelOpen()).toBe(true);
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
expect(isGeneratorPanelOpen()).toBe(false);
|
||||||
|
expect(document.querySelector('.gen-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Run the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bun run test 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 127 passed (was 124, added 3 new tests). If a test fails:
|
||||||
|
- Selector mismatch: confirm `.gen-panel` is the new host class and tests query that, not `.generator-popover`.
|
||||||
|
- Mount target mismatch: confirm tests pass `parent`+`trigger` not `anchor`.
|
||||||
|
- Save-link selector: still `#gen-save-default` (preserved per Step 1, item 10).
|
||||||
|
|
||||||
|
### Step 7: Type-check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bunx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: zero errors. If errors:
|
||||||
|
- `OpenPopoverOpts` is gone; tests/callers reference must use `OpenPanelOpts`. Should be caught by the import update.
|
||||||
|
- `onPicked` is now optional in `OpenPanelOpts` — TS may complain at the call site if not passed. The `fill-field` context needs `onPicked`; configure-defaults doesn't.
|
||||||
|
|
||||||
|
### Step 8: Build both bundles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bun run build:all 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "compiled with 2 warnings" (WASM size only) for each of Chrome and Firefox.
|
||||||
|
|
||||||
|
### Step 9: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario
|
||||||
|
git add extension/src/popup/components/generator-panel.ts \
|
||||||
|
extension/src/popup/components/__tests__/generator-panel.test.ts \
|
||||||
|
extension/src/popup/styles.css \
|
||||||
|
extension/src/popup/components/types/login.ts \
|
||||||
|
extension/src/popup/components/settings-vault.ts
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(ext/popup): rewrite generator as inline panel with ✨ trigger
|
||||||
|
|
||||||
|
The popover (which clipped off the popup edge) becomes an inline panel
|
||||||
|
that mounts inside the form (login.ts) or settings section
|
||||||
|
(settings-vault.ts). Trigger button is ✨ with aria-expanded toggling.
|
||||||
|
Action row varies by context: fill-field has cancel+use; configure-
|
||||||
|
defaults has only the save-default link. Escape key closes the panel.
|
||||||
|
Tests adapted to new API; 3 new tests for aria-expanded, auto-generate,
|
||||||
|
and Escape behavior.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Build, full verification, manual smoke
|
||||||
|
|
||||||
|
Working dir: `/home/alee/Sources/relicario`. Branch: main.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all test suites end to end**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario && cargo test --workspace 2>&1 | grep -E "test result" | tail -20
|
||||||
|
cd /home/alee/Sources/relicario/extension && bun run test 2>&1 | tail -5
|
||||||
|
cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Cargo: every "test result" line shows `0 failed`. Total ~155.
|
||||||
|
- Vitest: `Tests 127 passed (127)` (was 124; added 3 new generator-panel tests).
|
||||||
|
- tsc: zero output (no errors).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build both bundles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension && bun run build:all 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "compiled with 2 warnings" (WASM size only) for both Chrome and Firefox bundles.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final lint sweep — confirm no stale references to popover**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario && git grep -nE 'generator-popover|generatorPopover|openGeneratorPopover|closeGeneratorPopover|\.generator-popover' -- 'extension/src/' 'extension/setup.html'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: zero output. The only remaining occurrences allowed are inside markdown specs/plans (`docs/`) — these document the historical name and should NOT be modified.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual smoke test (relay these instructions to the user)**
|
||||||
|
|
||||||
|
Have the user reload the extension and walk through:
|
||||||
|
|
||||||
|
- **Login form:** Open popup → New → Login. Click ✨ button next to password input. Verify:
|
||||||
|
- Inline panel appears below the password row (not a clipped popover)
|
||||||
|
- Panel auto-fills with a generated preview immediately
|
||||||
|
- ✨ button shows gold-active state (`aria-expanded="true"`)
|
||||||
|
- Clicking length slider regenerates the preview after a brief debounce
|
||||||
|
- Toggling kind to "passphrase" switches knobs and regenerates
|
||||||
|
- "more ▾" disclosure expands to reveal symbol charset (random mode only)
|
||||||
|
- "use" button fills the password input and closes the panel
|
||||||
|
- "cancel" button closes the panel without committing
|
||||||
|
- Escape key closes the panel
|
||||||
|
- Clicking ✨ again while open closes the panel
|
||||||
|
- "↑ save these as default" link writes to vault settings (verify by reopening)
|
||||||
|
- **Vault settings:** Open ⚙ → vault settings → ✨ button next to generator preview. Verify:
|
||||||
|
- Inline panel appears inside the generator section
|
||||||
|
- No use/cancel buttons (configure-defaults context)
|
||||||
|
- "↑ save these as default" link works
|
||||||
|
- ✨ closes the panel
|
||||||
|
- **Polish:** All form labels are lowercase across all type forms. Required-field `*` markers are gold (`#aa812a`). Run through Login, SecureNote, Identity, Card, Key, TOTP forms briefly.
|
||||||
|
|
||||||
|
- [ ] **Step 5: No close-out commit needed if all green**
|
||||||
|
|
||||||
|
If steps 1-3 passed, the slice is complete via the prior 3 commits (label polish, rename, panel rewrite). If any fix was needed, commit as `fix(ext/popup): <description>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification summary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension && bun run build:all
|
||||||
|
cd /home/alee/Sources/relicario && cargo test --workspace
|
||||||
|
cd /home/alee/Sources/relicario/extension && bun run test
|
||||||
|
cd /home/alee/Sources/relicario/extension && bunx tsc --noEmit
|
||||||
|
git grep -nE 'generator-popover|generatorPopover|openGeneratorPopover|closeGeneratorPopover|\.generator-popover' -- 'extension/src/' 'extension/setup.html'
|
||||||
|
```
|
||||||
|
|
||||||
|
All five must succeed (grep returns nothing) for the slice to be complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **No worktree** — direct commits to main per project's single-maintainer flow.
|
||||||
|
- **Order matters:** Task 1 (label polish) is independent and ships first because it's harmless and doesn't depend on the panel rewrite. Task 2 (rename) MUST come before Task 3 because Task 3's commit message references `generator-panel.ts`. Task 3 must come before Task 4.
|
||||||
|
- **The `<details>` element** is the cleanest way to implement the "more ▾" disclosure — it's natively accessible and the CSS hides the default disclosure marker. Make sure the disclosure is conditionally rendered (only for random mode).
|
||||||
|
- **Test ID preservation:** the existing test asserts on specific element IDs (`#gen-kind-random`, `#gen-length`, `#gen-use`, `#gen-save-default`, `#gen-lower` etc.). The rewrite must keep those IDs intact, even if surrounding markup changes. Check the test file before completing the rewrite.
|
||||||
|
- **Don't add animation/transitions** — the spec explicitly defers those. Panel appears/disappears instantly.
|
||||||
|
- **Don't add click-outside-to-close** — the spec explicitly excludes it.
|
||||||
578
docs/superpowers/plans/2026-04-24-relicario-logo-refresh.md
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
# Logo Refresh + Extension Palette Shift 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:** Replace the current arched-niche-with-blue-gem logo with a round chapel-style theca + fleur-de-lis finial in burnished gold/deep red, and shift the extension's primary accent from GitHub-blue to the matching gold ramp.
|
||||||
|
|
||||||
|
**Architecture:** No new code paths or behavior changes — this is asset replacement (2 SVGs + 3 PNGs) and a static color palette swap across CSS + inline TS/HTML colors. The CLI/dark feel is preserved (backgrounds and text colors untouched). One CSS class rename (`sig-block--blue` → `sig-block--gold`) sweeps through the consumers + a test.
|
||||||
|
|
||||||
|
**Tech Stack:** SVG (hand-authored), ImageMagick (`magick` — preferred per project memory) for SVG → PNG, CSS, TypeScript, vitest, webpack.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-24-relicario-logo-refresh-design.md` (commit `4b7f1fd`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Replace master logo SVG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/icons/relicario-logo.svg` (overwrite entirely)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Overwrite the master SVG**
|
||||||
|
|
||||||
|
Replace the file contents with:
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="redTheca" cx="0.4" cy="0.35">
|
||||||
|
<stop offset="0%" stop-color="#9a1a1a"/>
|
||||||
|
<stop offset="100%" stop-color="#3a0a0a"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldRing" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#d2ab43"/>
|
||||||
|
<stop offset="50%" stop-color="#f5d97a"/>
|
||||||
|
<stop offset="100%" stop-color="#7c5719"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldHi" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#fde9a8"/>
|
||||||
|
<stop offset="100%" stop-color="#d2ab43"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Pedestal (compact) -->
|
||||||
|
<ellipse cx="110" cy="226" rx="44" ry="5" fill="url(#goldRing)"/>
|
||||||
|
<rect x="78" y="212" width="64" height="14" rx="2" fill="url(#goldRing)"/>
|
||||||
|
<rect x="98" y="202" width="24" height="12" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="110" cy="208" rx="14" ry="3" fill="#7c5719"/>
|
||||||
|
<ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/>
|
||||||
|
|
||||||
|
<!-- Body, bezel, theca -->
|
||||||
|
<circle cx="110" cy="130" r="72" fill="url(#goldRing)"/>
|
||||||
|
<path d="M 110 58 A 72 72 0 0 0 38 130" stroke="#fde9a8" stroke-width="2" fill="none" opacity="0.6"/>
|
||||||
|
<circle cx="110" cy="130" r="60" fill="#7c5719"/>
|
||||||
|
<circle cx="110" cy="130" r="56" fill="url(#redTheca)"/>
|
||||||
|
<ellipse cx="86" cy="108" rx="16" ry="7" fill="#ffffff" opacity="0.14" transform="rotate(-30 86 108)"/>
|
||||||
|
|
||||||
|
<!-- Asterisk gem with pinwheel facets -->
|
||||||
|
<g transform="translate(110, 130)">
|
||||||
|
<g transform="rotate(0)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(60)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(120)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(180)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(240)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(300)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<polygon points="0,-6 5.2,-3 5.2,3 0,6 -5.2,3 -5.2,-3" fill="#d2ab43" stroke="#7c5719" stroke-width="0.6"/>
|
||||||
|
<circle cx="-1.5" cy="-2" r="1.4" fill="#fff3cf"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Hinge collar -->
|
||||||
|
<rect x="98" y="50" width="24" height="10" rx="2" fill="url(#goldRing)"/>
|
||||||
|
<line x1="100" y1="55" x2="120" y2="55" stroke="#7c5719" stroke-width="0.8"/>
|
||||||
|
|
||||||
|
<!-- Fleur-de-lis -->
|
||||||
|
<g transform="translate(110, 50)">
|
||||||
|
<rect x="-3.5" y="-12" width="7" height="12" fill="url(#goldRing)"/>
|
||||||
|
<rect x="-16" y="-18" width="32" height="7" rx="1.5" fill="url(#goldRing)"/>
|
||||||
|
<rect x="-3" y="-19" width="6" height="9" rx="0.8" fill="#7c5719"/>
|
||||||
|
<path d="M 0 -18 Q -8 -36, -4 -54 Q -1 -62, 0 -64 Q 1 -62, 4 -54 Q 8 -36, 0 -18 Z" fill="url(#goldRing)"/>
|
||||||
|
<path d="M 0 -22 Q -2.5 -36, 0 -52 Q 2.5 -36, 0 -22 Z" fill="#7c5719" opacity="0.55"/>
|
||||||
|
<circle cx="0" cy="-66" r="2.5" fill="url(#goldHi)"/>
|
||||||
|
<path d="M -4 -18 Q -22 -22, -26 -38 Q -22 -50, -16 -50 Q -16 -38, -10 -32 Q -6 -28, -4 -28 Z" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="-25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(-20 -25 -44)"/>
|
||||||
|
<path d="M 4 -18 Q 22 -22, 26 -38 Q 22 -50, 16 -50 Q 16 -38, 10 -32 Q 6 -28, 4 -28 Z" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(20 25 -44)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it parses**
|
||||||
|
|
||||||
|
Run: `xmllint --noout extension/icons/relicario-logo.svg && echo OK`
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
If `xmllint` isn't installed, fallback: `python3 -c "import xml.etree.ElementTree as T; T.parse('extension/icons/relicario-logo.svg'); print('OK')"`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/icons/relicario-logo.svg
|
||||||
|
git commit -m "feat(icons): replace master logo with reliquary theca + fleur"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Replace 16 px logo SVG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/icons/relicario-logo-16.svg` (overwrite entirely)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Overwrite the 16 px SVG**
|
||||||
|
|
||||||
|
Replace the file contents with:
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="redThecaSm" cx="0.4" cy="0.35">
|
||||||
|
<stop offset="0%" stop-color="#9a1a1a"/>
|
||||||
|
<stop offset="100%" stop-color="#3a0a0a"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldRingSm" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#d2ab43"/>
|
||||||
|
<stop offset="50%" stop-color="#f5d97a"/>
|
||||||
|
<stop offset="100%" stop-color="#7c5719"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Body + theca -->
|
||||||
|
<circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/>
|
||||||
|
<circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/>
|
||||||
|
|
||||||
|
<!-- Asterisk-as-3-bars -->
|
||||||
|
<g transform="translate(8, 9)" stroke="#f5d97a" stroke-width="1.2" stroke-linecap="round">
|
||||||
|
<line x1="0" y1="-3" x2="0" y2="3"/>
|
||||||
|
<line x1="-2.6" y1="-1.5" x2="2.6" y2="1.5"/>
|
||||||
|
<line x1="-2.6" y1="1.5" x2="2.6" y2="-1.5"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="8" cy="9" r="0.7" fill="#fff3cf"/>
|
||||||
|
|
||||||
|
<!-- Fleur (3 tips) -->
|
||||||
|
<path d="M 8 0 L 7.2 2.5 L 8.8 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
<path d="M 5.6 2.5 L 6.5 1 L 7.3 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
<path d="M 10.4 2.5 L 9.5 1 L 8.7 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it parses**
|
||||||
|
|
||||||
|
Run: `xmllint --noout extension/icons/relicario-logo-16.svg && echo OK`
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/icons/relicario-logo-16.svg
|
||||||
|
git commit -m "feat(icons): replace 16px logo with bare medallion variant"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Regenerate icon PNGs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/icons/icon-16.png` (regenerate)
|
||||||
|
- Modify: `extension/icons/icon-48.png` (regenerate)
|
||||||
|
- Modify: `extension/icons/icon-128.png` (regenerate)
|
||||||
|
|
||||||
|
ImageMagick (`magick`, NOT `rsvg-convert`) is the project preference per memory. Density flag controls source-rasterization sharpness; 384 = 4× standard 96dpi.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate icon-16.png from the 16 px SVG**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
magick -background none extension/icons/relicario-logo-16.svg -resize 16x16 extension/icons/icon-16.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: `file extension/icons/icon-16.png`
|
||||||
|
Expected: `PNG image data, 16 x 16, ...`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Generate icon-48.png from the master SVG**
|
||||||
|
|
||||||
|
The master SVG has aspect ratio 220:240 (slightly taller than 1:1 because of the pedestal). ImageMagick's `-resize 48x48` preserves aspect ratio by default — output will be 44 × 48 (constrained by height). Use `-extent 48x48 -gravity center` to pad to a 48 × 48 square with transparent margins.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
magick -background none -density 384 extension/icons/relicario-logo.svg \
|
||||||
|
-resize 48x48 -gravity center -extent 48x48 \
|
||||||
|
extension/icons/icon-48.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: `file extension/icons/icon-48.png`
|
||||||
|
Expected: `PNG image data, 48 x 48, ...`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate icon-128.png from the master SVG**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
magick -background none -density 384 extension/icons/relicario-logo.svg \
|
||||||
|
-resize 128x128 -gravity center -extent 128x128 \
|
||||||
|
extension/icons/icon-128.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify: `file extension/icons/icon-128.png`
|
||||||
|
Expected: `PNG image data, 128 x 128, ...`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Visual sanity check**
|
||||||
|
|
||||||
|
Open each PNG to confirm the gold/red logo is visible at the right size. From the terminal:
|
||||||
|
```bash
|
||||||
|
ls -la extension/icons/icon-*.png
|
||||||
|
```
|
||||||
|
Expected file sizes: icon-16 < icon-48 < icon-128. Each non-empty.
|
||||||
|
|
||||||
|
If a viewer is available (eog, feh, xdg-open), open `extension/icons/icon-128.png` and verify visually: gold ring, red theca with gold asterisk gem, fleur-de-lis on top, compact pedestal at bottom. Centered with transparent margins.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/icons/icon-16.png extension/icons/icon-48.png extension/icons/icon-128.png
|
||||||
|
git commit -m "feat(icons): regenerate PNGs from refreshed SVG masters"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Palette swap in styles.css
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/styles.css`
|
||||||
|
|
||||||
|
The complete mapping from old to new hex values:
|
||||||
|
|
||||||
|
| Old | New | Note |
|
||||||
|
|-----|-----|------|
|
||||||
|
| `#58a6ff` | `#d2ab43` | bright gold replaces primary blue |
|
||||||
|
| `#1f6feb` | `#7c5719` | deep gold replaces deep blue |
|
||||||
|
| `#388bfd` | `#aa812a` | mid gold replaces mid blue (hover state) |
|
||||||
|
| `#f85149` | `#ab2b20` | theca-toned red replaces danger fg |
|
||||||
|
| `#da3633` | `#791111` | deep theca-red replaces danger emphasis |
|
||||||
|
| `rgba(88, 166, 255, 0.3)` | `rgba(170, 129, 42, 0.4)` | focus ring tint (slightly more saturated) |
|
||||||
|
|
||||||
|
Note: `#3fb950` (success green) and `#d29922` (warning yellow) are NOT changed.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Apply find-and-replace to styles.css**
|
||||||
|
|
||||||
|
Use `sed` (in-place) for the bulk swap:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i \
|
||||||
|
-e 's/#58a6ff/#d2ab43/g' \
|
||||||
|
-e 's/#1f6feb/#7c5719/g' \
|
||||||
|
-e 's/#388bfd/#aa812a/g' \
|
||||||
|
-e 's/#f85149/#ab2b20/g' \
|
||||||
|
-e 's/#da3633/#791111/g' \
|
||||||
|
-e 's/rgba(88, *166, *255, *\([0-9.]*\))/rgba(170, 129, 42, \1)/g' \
|
||||||
|
-e 's/rgba(31, *111, *235, *\([0-9.]*\))/rgba(124, 87, 25, \1)/g' \
|
||||||
|
-e 's/rgba(248, *81, *73, *\([0-9.]*\))/rgba(171, 43, 32, \1)/g' \
|
||||||
|
-e 's/rgba(218, *54, *51, *\([0-9.]*\))/rgba(121, 17, 17, \1)/g' \
|
||||||
|
extension/src/popup/styles.css
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no old colors remain**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/src/popup/styles.css
|
||||||
|
```
|
||||||
|
Expected: no output (zero hits).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -nE 'rgba\(88|rgba\(31, *111|rgba\(248, *81|rgba\(218, *54' extension/src/popup/styles.css
|
||||||
|
```
|
||||||
|
Expected: no output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run vitest (CSS changes shouldn't break behavior tests)**
|
||||||
|
|
||||||
|
Run: `cd extension && bun run test`
|
||||||
|
Expected: 124 passed (one test inspects HTML for `sig-block--blue` and will still pass at this point — that fix lands in Task 5).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/popup/styles.css
|
||||||
|
git commit -m "feat(ext/popup): swap blue accent palette for burnished gold"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Rename `sig-block--blue` to `sig-block--gold`
|
||||||
|
|
||||||
|
The `--blue` variant of the signature block now renders gold. Rename the class for semantic correctness.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/styles.css` (the class definition)
|
||||||
|
- Modify: `extension/src/popup/components/fields.ts` (the consumer that emits the class string)
|
||||||
|
- Modify: `extension/src/popup/components/__tests__/fields.test.ts` (the assertion)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find all consumers of `sig-block--blue` and `accent: 'blue'`**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rn "sig-block--blue\|accent: 'blue'\|accent=\"blue\"\|accent: \"blue\"" extension/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected hits:
|
||||||
|
- `extension/src/popup/styles.css:507` — `.sig-block--blue { ... }`
|
||||||
|
- `extension/src/popup/components/__tests__/fields.test.ts` — string literals `'sig-block--blue'`, `accent: 'blue'`
|
||||||
|
|
||||||
|
The `extension/src/popup/components/fields.ts` template uses `sig-block--${accent}` so it accepts whatever string the caller passes. We need to find any caller that passes `'blue'`.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rn "renderSignatureBlock" extension/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect each call site for an `accent: 'blue'` argument; rename to `accent: 'gold'`. Likely zero or one site outside the test, since most signature-block consumers use `'green'` / `'amber'` / `'red'` for status semantics.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rename in styles.css**
|
||||||
|
|
||||||
|
Edit `extension/src/popup/styles.css` line 507:
|
||||||
|
```css
|
||||||
|
.sig-block--gold { border-left-color: #7c5719; }
|
||||||
|
```
|
||||||
|
|
||||||
|
(was `.sig-block--blue { border-left-color: #1f6feb; }` — the color was already swapped to `#7c5719` in Task 4; now we rename the class.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rename `accent` type union in fields.ts**
|
||||||
|
|
||||||
|
Open `extension/src/popup/components/fields.ts`. The `renderSignatureBlock` opts type likely has `accent: 'blue' | 'green' | 'amber' | 'red'`. Replace `'blue'` with `'gold'`:
|
||||||
|
|
||||||
|
Find:
|
||||||
|
```ts
|
||||||
|
accent: 'blue' | 'green' | 'amber' | 'red'
|
||||||
|
```
|
||||||
|
(or however it's typed — also check for `accent?: ...` and `Accent` aliases)
|
||||||
|
|
||||||
|
Replace `'blue'` with `'gold'`. Adjust any default value (e.g. `accent = 'blue'` → `accent = 'gold'`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rename in fields.test.ts**
|
||||||
|
|
||||||
|
Edit `extension/src/popup/components/__tests__/fields.test.ts`:
|
||||||
|
|
||||||
|
Line 72-area: change `expect(html).toContain('sig-block--blue');` to `expect(html).toContain('sig-block--gold');`
|
||||||
|
|
||||||
|
Line 77-area: change `accent: 'blue'` to `accent: 'gold'`. The assertion line 77 likely reads:
|
||||||
|
```ts
|
||||||
|
expect(renderSignatureBlock({ accent: 'blue', children: '' })).toContain('sig-block--blue');
|
||||||
|
```
|
||||||
|
Becomes:
|
||||||
|
```ts
|
||||||
|
expect(renderSignatureBlock({ accent: 'gold', children: '' })).toContain('sig-block--gold');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update any non-test callers found in Step 1**
|
||||||
|
|
||||||
|
For each non-test call site that passes `accent: 'blue'`, change to `accent: 'gold'`. If Step 1 found zero such sites, skip this step.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run vitest**
|
||||||
|
|
||||||
|
Run: `cd extension && bun run test`
|
||||||
|
Expected: 124 passed (the renamed test now asserts on `'gold'` and matches the renamed class).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify type-check is clean**
|
||||||
|
|
||||||
|
Run: `cd extension && bunx tsc --noEmit`
|
||||||
|
Expected: zero errors. (If the `accent` type union missed a spot, this is where it'll surface.)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/popup/styles.css \
|
||||||
|
extension/src/popup/components/fields.ts \
|
||||||
|
extension/src/popup/components/__tests__/fields.test.ts
|
||||||
|
git commit -m "feat(ext/popup): rename sig-block--blue to --gold for accuracy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Inline color sweep in TS files
|
||||||
|
|
||||||
|
Six TS files have inline hex colors in template literals or DOM-style assignments. Each is a 1–2 line touch.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/components/types/login.ts`
|
||||||
|
- Modify: `extension/src/popup/components/types/totp.ts`
|
||||||
|
- Modify: `extension/src/popup/components/generator-popover.ts`
|
||||||
|
- Modify: `extension/src/popup/components/settings.ts`
|
||||||
|
- Modify: `extension/src/content/capture.ts`
|
||||||
|
- Modify: `extension/src/content/icon.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Sweep all six files with sed**
|
||||||
|
|
||||||
|
Same color mapping as Task 4. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i \
|
||||||
|
-e 's/#58a6ff/#d2ab43/g' \
|
||||||
|
-e 's/#1f6feb/#7c5719/g' \
|
||||||
|
-e 's/#388bfd/#aa812a/g' \
|
||||||
|
-e 's/#f85149/#ab2b20/g' \
|
||||||
|
-e 's/#da3633/#791111/g' \
|
||||||
|
extension/src/popup/components/types/login.ts \
|
||||||
|
extension/src/popup/components/types/totp.ts \
|
||||||
|
extension/src/popup/components/generator-popover.ts \
|
||||||
|
extension/src/popup/components/settings.ts \
|
||||||
|
extension/src/content/capture.ts \
|
||||||
|
extension/src/content/icon.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no old colors remain in `extension/src/`**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rnE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/src/
|
||||||
|
```
|
||||||
|
Expected: no output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run vitest + type-check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bun run test && bunx tsc --noEmit
|
||||||
|
```
|
||||||
|
Expected: 124 passed; zero TS errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/src/popup/components/types/login.ts \
|
||||||
|
extension/src/popup/components/types/totp.ts \
|
||||||
|
extension/src/popup/components/generator-popover.ts \
|
||||||
|
extension/src/popup/components/settings.ts \
|
||||||
|
extension/src/content/capture.ts \
|
||||||
|
extension/src/content/icon.ts
|
||||||
|
git commit -m "feat(ext): sweep inline blue/red colors to gold/theca-red"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Inline color sweep in `setup.html`
|
||||||
|
|
||||||
|
Same swap pattern, but in HTML/CSS context.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/setup.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Apply sed sweep to setup.html**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i \
|
||||||
|
-e 's/#58a6ff/#d2ab43/g' \
|
||||||
|
-e 's/#1f6feb/#7c5719/g' \
|
||||||
|
-e 's/#388bfd/#aa812a/g' \
|
||||||
|
-e 's/#f85149/#ab2b20/g' \
|
||||||
|
-e 's/#da3633/#791111/g' \
|
||||||
|
extension/setup.html
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no old colors remain in setup.html**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/setup.html
|
||||||
|
```
|
||||||
|
Expected: no output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify final scope: zero stale colors anywhere in extension/src/ + setup.html**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rnE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/src/ extension/setup.html
|
||||||
|
```
|
||||||
|
Expected: no output. **This is the spec's primary acceptance gate.**
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add extension/setup.html
|
||||||
|
git commit -m "feat(ext/setup): sweep inline colors for palette refresh"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Build, full verification, and close-out
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build both extension bundles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd extension && bun run build:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "compiled with 2 warnings" (WASM size warnings only) for both Chrome and Firefox.
|
||||||
|
|
||||||
|
If webpack errors appear, the most likely cause is a TS type mismatch from Task 5's `accent` type union. Re-run `bunx tsc --noEmit` and fix.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full test sweep**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario && cargo test --workspace
|
||||||
|
cd /home/alee/Sources/relicario/extension && bun run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 155 Rust + 124 Vitest, all green.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual visual smoke check (instructions for the implementer to relay to user)**
|
||||||
|
|
||||||
|
Have the user load `extension/dist/` in Chrome (`chrome://extensions` → "Update" if already loaded, or "Load unpacked" otherwise) and verify:
|
||||||
|
|
||||||
|
- [ ] Toolbar icon shows the new gold/red reliquary medallion (16 px treatment).
|
||||||
|
- [ ] Open popup → unlock — primary buttons (`+ New`, `autofill`, `save`) have gold backgrounds (`#7c5719`).
|
||||||
|
- [ ] Selected list row has a gold left-border (`#aa812a`) + gold tint background.
|
||||||
|
- [ ] Focus ring on search input + form fields is gold (`#aa812a` @ 40%).
|
||||||
|
- [ ] Reveal/copy links in detail view are bright gold (`#d2ab43`).
|
||||||
|
- [ ] Trash button (and any danger states) shows theca-red (`#ab2b20`).
|
||||||
|
- [ ] TOTP countdown ring is gold (`#d2ab43`).
|
||||||
|
- [ ] Signature blocks: `--gold` accent renders gold (was the old blue accent).
|
||||||
|
- [ ] Setup tab: strength bar's "very weak" segment is theca-red; advice block left-border is gold.
|
||||||
|
- [ ] Capture prompt and origin-ack icon (content script) use gold + theca-red.
|
||||||
|
|
||||||
|
Repeat in Firefox via `about:debugging` → "Update" or "Load Temporary Add-on" → `extension/dist-firefox/manifest.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final acceptance grep (paranoia check)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' -- 'extension/src/**' 'extension/setup.html'
|
||||||
|
```
|
||||||
|
Expected: no output. Anything in `dist/`, `dist-firefox/`, `node_modules/`, or `.superpowers/` is out of scope.
|
||||||
|
|
||||||
|
- [ ] **Step 5: No close-out commit needed**
|
||||||
|
|
||||||
|
If steps 1–4 all passed without changes, there's nothing left to commit. The seven prior commits cover all changes.
|
||||||
|
|
||||||
|
If a fix was needed in step 1 or step 3 (e.g., a missed `accent: 'blue'` consumer), commit that fix as `fix(ext): <description>` before closing out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification summary (run after Task 8)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bundles compile clean
|
||||||
|
cd extension && bun run build:all
|
||||||
|
|
||||||
|
# All tests pass
|
||||||
|
cd /home/alee/Sources/relicario && cargo test --workspace
|
||||||
|
cd /home/alee/Sources/relicario/extension && bun run test
|
||||||
|
|
||||||
|
# Stale palette purged
|
||||||
|
git grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' -- 'extension/src/**' 'extension/setup.html' # zero hits
|
||||||
|
|
||||||
|
# Type-check clean
|
||||||
|
cd extension && bunx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
All four checks must succeed for the plan to be considered complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **No new tests** — palette + logo are visual changes; existing tests cover behavior. The one test touched (`fields.test.ts`) is updated for the renamed `--gold` class.
|
||||||
|
- **No worktree required** — this is a small, atomic change set. Commits go directly to main per the project's single-maintainer flow.
|
||||||
|
- **Order matters slightly:** Task 4 swaps `#1f6feb` → `#7c5719` everywhere in styles.css, including inside the old `.sig-block--blue` rule. Task 5 then renames the class. Don't reverse the order or the sed sweep in Task 4 will skip the value because the class context changed.
|
||||||
|
- **PNG generation order matters:** Task 3 needs the SVGs from Tasks 1–2 to exist first.
|
||||||
|
- **Brainstorm artifacts** in `.superpowers/brainstorm/` contain the old hex values in mockup HTML — those are gitignored and out of scope; do NOT sed-sweep them.
|
||||||
2305
docs/superpowers/plans/2026-04-26-relicario-extension-1c-gamma2.md
Normal file
1533
docs/superpowers/plans/2026-04-27-attach-existing-vault.md
Normal file
2797
docs/superpowers/plans/2026-04-27-relicario-backup-restore.md
Normal file
1441
docs/superpowers/plans/2026-04-27-vault-tab-session-timeout.md
Normal file
2559
docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md
Normal file
@@ -0,0 +1,948 @@
|
|||||||
|
# Fullscreen UX redesign — Phase 1: Visual Foundation
|
||||||
|
|
||||||
|
> **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:** Establish the shared visual language (glyph constants, color tokens, focus ring, required pill, header subtitle) and clean up vestigial popup-only UI in the fullscreen vault. No structural or behavioral changes; pure visual foundation that the next three phases will build on.
|
||||||
|
|
||||||
|
**Architecture:** A new `extension/src/shared/glyphs.ts` module exports unicode glyph constants and a `REQUIRED_PILL_HTML` HTML snippet, consumed by both popup and fullscreen surfaces. CSS custom properties added to `popup/styles.css` and `vault/vault.css` provide the shared color/focus tokens. All eight type forms migrate from `<span class="req">*</span>` to the pill; sidebar nav buttons replace emoji with glyph constants; the popout-to-tab button is gated behind `!isInTab()` so it disappears in fullscreen. Fullscreen forms gain a static "esc to cancel" subtitle (dynamic dirty-state lands in Phase 3).
|
||||||
|
|
||||||
|
**Tech stack:** TypeScript, vanilla DOM (no framework), Vitest + happy-dom for unit tests. No new runtime dependencies.
|
||||||
|
|
||||||
|
**Spec:** [`docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md`](../specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: shared/glyphs.ts module + snapshot test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `extension/src/shared/glyphs.ts`
|
||||||
|
- Create: `extension/src/shared/__tests__/glyphs.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// extension/src/shared/__tests__/glyphs.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as glyphs from '../glyphs';
|
||||||
|
|
||||||
|
describe('glyphs', () => {
|
||||||
|
it('exports the documented glyph constants', () => {
|
||||||
|
expect(glyphs.GLYPH_REVEAL).toBe('⊙');
|
||||||
|
expect(glyphs.GLYPH_HIDE).toBe('⊘');
|
||||||
|
expect(glyphs.GLYPH_GENERATE).toBe('↻');
|
||||||
|
expect(glyphs.GLYPH_FILL_FROM_TAB).toBe('⤓');
|
||||||
|
expect(glyphs.GLYPH_QR).toBe('◫');
|
||||||
|
expect(glyphs.GLYPH_MONO).toBe('≡');
|
||||||
|
expect(glyphs.GLYPH_TRASH).toBe('▦');
|
||||||
|
expect(glyphs.GLYPH_DEVICES).toBe('⌬');
|
||||||
|
expect(glyphs.GLYPH_SETTINGS).toBe('⚙');
|
||||||
|
expect(glyphs.GLYPH_LOCK).toBe('⏻');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports REQUIRED_PILL_HTML as an HTML snippet', () => {
|
||||||
|
expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/shared/__tests__/glyphs.test.ts`
|
||||||
|
Expected: FAIL with module-not-found / unresolved-import error.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the glyphs module**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// extension/src/shared/glyphs.ts
|
||||||
|
//
|
||||||
|
// Unicode glyph constants used across popup and fullscreen surfaces. All
|
||||||
|
// glyphs are monochrome unicode (no emoji) so they render identically in the
|
||||||
|
// codebase's monospace font. Pair each button glyph with a `title=` tooltip
|
||||||
|
// at the call site for accessibility — the constants here are the visual,
|
||||||
|
// not the affordance.
|
||||||
|
|
||||||
|
export const GLYPH_REVEAL = '⊙'; // password reveal toggle (hidden state)
|
||||||
|
export const GLYPH_HIDE = '⊘'; // password reveal toggle (revealed state)
|
||||||
|
export const GLYPH_GENERATE = '↻'; // password / passphrase generate
|
||||||
|
export const GLYPH_FILL_FROM_TAB = '⤓'; // pull URL from active browser tab
|
||||||
|
export const GLYPH_QR = '◫'; // paste / upload QR image (TOTP)
|
||||||
|
export const GLYPH_MONO = '≡'; // toggle notes monospace font
|
||||||
|
|
||||||
|
export const GLYPH_TRASH = '▦'; // sidebar trash nav
|
||||||
|
export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
|
||||||
|
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
|
||||||
|
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
||||||
|
|
||||||
|
/// Inline HTML snippet for the required-field pill. Use after a label's text:
|
||||||
|
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
|
||||||
|
export const REQUIRED_PILL_HTML = '<span class="req-pill">required</span>';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/shared/__tests__/glyphs.test.ts`
|
||||||
|
Expected: PASS, 2/2 tests green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/shared/glyphs.ts extension/src/shared/__tests__/glyphs.test.ts
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "feat(ext/shared): glyph constants module for unified icon language
|
||||||
|
|
||||||
|
Centralizes the unicode glyphs used by sidebar nav and form action buttons
|
||||||
|
so popup and fullscreen surfaces stay in sync. Includes the REQUIRED_PILL_HTML
|
||||||
|
snippet used to replace the trailing-asterisk required-field marker.
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 1.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Color tokens + focus ring (popup styles.css)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/styles.css:1-150`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add color tokens at the top of the file**
|
||||||
|
|
||||||
|
Open `extension/src/popup/styles.css` and add a `:root` block immediately after the leading comment (before the `*` reset on line 3):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* relicario extension — terminal dark theme */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Brand */
|
||||||
|
--accent: #d2ab43;
|
||||||
|
--accent-soft: rgba(210, 171, 67, 0.18);
|
||||||
|
--accent-strong: #aa812a;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--bg-page: #0d1117;
|
||||||
|
--bg-pane: #161b22;
|
||||||
|
--bg-elevated: #21262d;
|
||||||
|
--border-subtle: #30363d;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--text-dim: #484f58;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--danger: #ab2b20;
|
||||||
|
--danger-bg: #791111;
|
||||||
|
--success: #6cb37a;
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
--focus-ring: 0 0 0 2px rgba(210, 171, 67, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update input focus to use the ring token**
|
||||||
|
|
||||||
|
Find the existing input focus rule (around line 136) and replace it:
|
||||||
|
|
||||||
|
Before:
|
||||||
|
```css
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
border-color: #d2ab43;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
```css
|
||||||
|
input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update button focus to match**
|
||||||
|
|
||||||
|
Find the `.btn:focus` rule (around line 97) and replace:
|
||||||
|
|
||||||
|
Before:
|
||||||
|
```css
|
||||||
|
.btn:focus {
|
||||||
|
outline: 1px solid #d2ab43;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
```css
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the required-field pill style**
|
||||||
|
|
||||||
|
Find the `.label .req` rule (around line 58) and add the pill rule immediately after it:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.label .req {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
margin-left: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the popup to verify CSS parses**
|
||||||
|
|
||||||
|
Run: `cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5`
|
||||||
|
Expected: `webpack ... compiled with 2 warnings` (the existing wasm size warnings; no CSS errors).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/popup/styles.css
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "style(ext/popup): add color tokens, focus ring, required-pill class
|
||||||
|
|
||||||
|
Establishes :root CSS custom properties (accent, surfaces, status, focus
|
||||||
|
ring) and applies the focus ring to inputs/buttons via :focus-visible.
|
||||||
|
Adds .req-pill class used by Task 4 to replace the bare-asterisk required
|
||||||
|
marker. Existing .label .req kept for backward compatibility during the
|
||||||
|
migration window.
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 2.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Color tokens + focus ring (vault.css)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/vault/vault.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the same `:root` block to vault.css**
|
||||||
|
|
||||||
|
Open `extension/src/vault/vault.css` and add the same `:root` block at the top (above any existing content). Use the **identical** token block from Task 2 Step 1 so the two stylesheets stay in sync:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Brand */
|
||||||
|
--accent: #d2ab43;
|
||||||
|
--accent-soft: rgba(210, 171, 67, 0.18);
|
||||||
|
--accent-strong: #aa812a;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--bg-page: #0d1117;
|
||||||
|
--bg-pane: #161b22;
|
||||||
|
--bg-elevated: #21262d;
|
||||||
|
--border-subtle: #30363d;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--text-dim: #484f58;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--danger: #ab2b20;
|
||||||
|
--danger-bg: #791111;
|
||||||
|
--success: #6cb37a;
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
--focus-ring: 0 0 0 2px rgba(210, 171, 67, 0.35);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Find existing input focus rule and migrate it**
|
||||||
|
|
||||||
|
Run: `grep -n "input:focus\|textarea:focus\|:focus" extension/src/vault/vault.css | head -10`
|
||||||
|
|
||||||
|
For each focus rule that sets `border-color: #d2ab43` (or similar accent-color border), update it to use `:focus-visible` and add the ring:
|
||||||
|
|
||||||
|
```css
|
||||||
|
input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(If no equivalent rule exists in vault.css today, add the rule above; vault inputs currently inherit popup styles or have their own — check what `grep` returns.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the .req-pill rule**
|
||||||
|
|
||||||
|
Append to vault.css (anywhere; group near `.label` if present):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.req-pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build to verify**
|
||||||
|
|
||||||
|
Run: `cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5`
|
||||||
|
Expected: `webpack ... compiled with 2 warnings`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/vault/vault.css
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "style(ext/vault): mirror color tokens, focus ring, required-pill class
|
||||||
|
|
||||||
|
Same :root block and .req-pill rule as popup/styles.css so the two
|
||||||
|
stylesheets share visual tokens. Vault input focus migrated to
|
||||||
|
:focus-visible + box-shadow ring.
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 3.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Migrate required-marker sites to REQUIRED_PILL_HTML
|
||||||
|
|
||||||
|
**Files (10 sites across 7 files):**
|
||||||
|
- Modify: `extension/src/popup/components/types/card.ts:182`
|
||||||
|
- Modify: `extension/src/popup/components/types/document.ts:94, 98`
|
||||||
|
- Modify: `extension/src/popup/components/types/identity.ts:142`
|
||||||
|
- Modify: `extension/src/popup/components/types/key.ts:131, 133`
|
||||||
|
- Modify: `extension/src/popup/components/types/login.ts:252`
|
||||||
|
- Modify: `extension/src/popup/components/types/secure-note.ts:120`
|
||||||
|
- Modify: `extension/src/popup/components/types/totp.ts:221, 230`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create a regression test for the login form's title label**
|
||||||
|
|
||||||
|
Create `extension/src/popup/components/types/__tests__/required-pill.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../../shared/state', () => ({
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
getState: () => ({ newType: 'login', generatorDefaults: null, error: null, loading: false, vaultSettings: null, entries: [] }),
|
||||||
|
setState: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: () => false,
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
registerHost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../generator-panel', () => ({
|
||||||
|
openGeneratorPanel: vi.fn(),
|
||||||
|
closeGeneratorPanel: vi.fn(),
|
||||||
|
isGeneratorPanelOpen: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { renderForm } from '../login';
|
||||||
|
|
||||||
|
describe('required-pill migration', () => {
|
||||||
|
beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });
|
||||||
|
|
||||||
|
it('login form title uses the required pill', () => {
|
||||||
|
renderForm(document.getElementById('app')!, 'add', null);
|
||||||
|
const titleLabel = document.querySelector('label[for="f-title"]');
|
||||||
|
expect(titleLabel?.innerHTML).toContain('required');
|
||||||
|
expect(titleLabel?.innerHTML).not.toContain('<span class="req">*</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/required-pill.test.ts`
|
||||||
|
Expected: FAIL — `<span class="req">*</span>` is currently present, `required` text is not.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Migrate `login.ts`**
|
||||||
|
|
||||||
|
In `extension/src/popup/components/types/login.ts`:
|
||||||
|
|
||||||
|
Add an import near the top (after the existing imports):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
|
||||||
|
```
|
||||||
|
|
||||||
|
Find line 252:
|
||||||
|
```typescript
|
||||||
|
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```typescript
|
||||||
|
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes for login**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/required-pill.test.ts`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Migrate the remaining six files**
|
||||||
|
|
||||||
|
Apply the same pattern to each of these six files. For each:
|
||||||
|
1. Add `import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';`
|
||||||
|
2. Replace each `<span class="req">*</span>` with `${REQUIRED_PILL_HTML}`
|
||||||
|
|
||||||
|
| File | Line(s) |
|
||||||
|
|---|---|
|
||||||
|
| `extension/src/popup/components/types/card.ts` | 182 |
|
||||||
|
| `extension/src/popup/components/types/document.ts` | 94, 98 |
|
||||||
|
| `extension/src/popup/components/types/identity.ts` | 142 |
|
||||||
|
| `extension/src/popup/components/types/key.ts` | 131, 133 |
|
||||||
|
| `extension/src/popup/components/types/secure-note.ts` | 120 |
|
||||||
|
| `extension/src/popup/components/types/totp.ts` | 221, 230 |
|
||||||
|
|
||||||
|
After editing each file, verify no remaining `<span class="req">*</span>` strings exist:
|
||||||
|
|
||||||
|
Run: `grep -rn 'class="req"' extension/src --include="*.ts" 2>/dev/null`
|
||||||
|
Expected: empty output.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full extension test suite**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run`
|
||||||
|
Expected: all 220+ tests pass (the new test brings it to 221+; no regressions).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build to verify TypeScript compiles**
|
||||||
|
|
||||||
|
Run: `cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5`
|
||||||
|
Expected: `compiled with 2 warnings`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/popup/components/types/ extension/src/shared/
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "refactor(ext/popup): migrate required-field markers to REQUIRED_PILL_HTML
|
||||||
|
|
||||||
|
Replaces ten <span class=\"req\">*</span> sites across all seven type
|
||||||
|
forms with the shared REQUIRED_PILL_HTML snippet ('required' badge). Adds a
|
||||||
|
regression test pinning the new HTML in the login form.
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 4.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Migrate vault sidebar nav glyphs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/vault/vault.ts:251-254`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a regression test**
|
||||||
|
|
||||||
|
Open `extension/src/vault/components/__tests__/import-panel.test.ts` for reference on how vault tests mock state. Create a new test file:
|
||||||
|
|
||||||
|
`extension/src/vault/__tests__/sidebar-glyphs.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../../shared/glyphs';
|
||||||
|
|
||||||
|
// vault.ts injects HTML into document.getElementById('vault-app'); we
|
||||||
|
// don't need to invoke render() — we just need to scan the source for the
|
||||||
|
// emoji we removed.
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
describe('vault sidebar glyphs', () => {
|
||||||
|
const vaultSrc = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../vault.ts'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('uses GLYPH_TRASH instead of the trash emoji', () => {
|
||||||
|
expect(vaultSrc).not.toMatch(/\u{1F5D1}/u);
|
||||||
|
expect(vaultSrc).toContain('GLYPH_TRASH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses GLYPH_DEVICES instead of the devices emoji', () => {
|
||||||
|
expect(vaultSrc).not.toMatch(/\u{1F4F1}/u);
|
||||||
|
expect(vaultSrc).toContain('GLYPH_DEVICES');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses GLYPH_LOCK instead of the lock emoji', () => {
|
||||||
|
expect(vaultSrc).not.toMatch(/\u{1F512}/u);
|
||||||
|
expect(vaultSrc).toContain('GLYPH_LOCK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses GLYPH_SETTINGS for the settings nav', () => {
|
||||||
|
expect(vaultSrc).toContain('GLYPH_SETTINGS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/vault/__tests__/sidebar-glyphs.test.ts`
|
||||||
|
Expected: FAIL — the emojis are still present, the GLYPH constants are not.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the import to vault.ts**
|
||||||
|
|
||||||
|
In `extension/src/vault/vault.ts`, add to the imports section (near the top, after other shared imports):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace the sidebar nav buttons**
|
||||||
|
|
||||||
|
Find the block at lines 249-255 in `vault.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<div class="vault-sidebar__nav">
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="trash">\u{1F5D1} trash</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="devices">\u{1F4F1} devices</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="settings">⚙ settings</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="lock">\u{1F512} lock</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<div class="vault-sidebar__nav">
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="trash">${GLYPH_TRASH} trash</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
|
||||||
|
<button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} lock</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/vault/__tests__/sidebar-glyphs.test.ts`
|
||||||
|
Expected: PASS, 4/4 tests green.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full suite + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension
|
||||||
|
./node_modules/.bin/vitest run 2>&1 | tail -5
|
||||||
|
./node_modules/.bin/webpack --mode production 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
Expected: all tests pass; webpack compiles with 2 warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/vault/vault.ts extension/src/vault/__tests__/sidebar-glyphs.test.ts
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "style(ext/vault): replace sidebar emoji nav with monochrome glyphs
|
||||||
|
|
||||||
|
▦ trash · ⌬ devices · ⚙ settings · ⏻ lock — all imported from the new
|
||||||
|
shared/glyphs module so popup and fullscreen stay in sync. Regression
|
||||||
|
test scans the source for the old escape-coded emoji to prevent
|
||||||
|
backsliding.
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 5.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Migrate popup settings nav glyphs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/components/settings.ts:58-59`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify the existing emojis**
|
||||||
|
|
||||||
|
Run: `grep -n "🗑\|🔐" extension/src/popup/components/settings.ts`
|
||||||
|
Expected output (line 58 trash, line 59 devices):
|
||||||
|
```
|
||||||
|
58: <button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button>
|
||||||
|
59: <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the import**
|
||||||
|
|
||||||
|
In `extension/src/popup/components/settings.ts`, add to the imports near the top:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the buttons**
|
||||||
|
|
||||||
|
Replace lines 58-59:
|
||||||
|
|
||||||
|
Before:
|
||||||
|
```typescript
|
||||||
|
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button>
|
||||||
|
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
```typescript
|
||||||
|
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
|
||||||
|
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
(Lowercased "trash" / "devices" to match the brand's lowercase aesthetic established in Phase 1.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify no emojis remain**
|
||||||
|
|
||||||
|
Run: `grep -n "🗑\|🔐\|🔒\|📺" extension/src/popup/components/settings.ts`
|
||||||
|
Expected: empty output.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension
|
||||||
|
./node_modules/.bin/vitest run 2>&1 | tail -5
|
||||||
|
./node_modules/.bin/webpack --mode production 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
Expected: all tests pass; webpack compiles with 2 warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/popup/components/settings.ts
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "style(ext/popup): replace settings nav emoji with shared glyphs
|
||||||
|
|
||||||
|
▦ trash and ⌬ devices in the popup settings panel now match the
|
||||||
|
fullscreen sidebar's glyph language. Lowercased labels match the brand.
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 6.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Hide popout-to-tab button in fullscreen forms
|
||||||
|
|
||||||
|
**Files (8 sites):**
|
||||||
|
- Modify: `extension/src/popup/components/item-form.ts:61`
|
||||||
|
- Modify: `extension/src/popup/components/types/card.ts:179`
|
||||||
|
- Modify: `extension/src/popup/components/types/document.ts:90`
|
||||||
|
- Modify: `extension/src/popup/components/types/identity.ts:139`
|
||||||
|
- Modify: `extension/src/popup/components/types/key.ts:128`
|
||||||
|
- Modify: `extension/src/popup/components/types/login.ts:249`
|
||||||
|
- Modify: `extension/src/popup/components/types/secure-note.ts:117`
|
||||||
|
- Modify: `extension/src/popup/components/types/totp.ts:218`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm `isInTab()` is exported and used**
|
||||||
|
|
||||||
|
Run: `grep -n "export.*isInTab\|import.*isInTab" extension/src/shared/state.ts extension/src/popup/components/types/login.ts`
|
||||||
|
Expected: `state.ts` exports `isInTab`; `login.ts` already imports it.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write a test for the login form behavior in fullscreen**
|
||||||
|
|
||||||
|
Append to `extension/src/popup/components/types/__tests__/required-pill.test.ts` (or create a new file `popout-button.test.ts` next to it):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Append to required-pill.test.ts
|
||||||
|
|
||||||
|
describe('popout-to-tab button visibility', () => {
|
||||||
|
beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });
|
||||||
|
|
||||||
|
it('renders the popout button when isInTab() is false (popup context)', async () => {
|
||||||
|
// The default mock at the top of this file sets isInTab: () => false.
|
||||||
|
// Re-render with that.
|
||||||
|
const { renderForm } = await import('../login');
|
||||||
|
renderForm(document.getElementById('app')!, 'add', null);
|
||||||
|
expect(document.getElementById('popout-btn')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
For the fullscreen variant (isInTab → true), add a separate test file because vi.mock is module-level. Create `extension/src/popup/components/types/__tests__/popout-fullscreen.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../../shared/state', () => ({
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
getState: () => ({ newType: 'login', generatorDefaults: null, error: null, loading: false, vaultSettings: null, entries: [] }),
|
||||||
|
setState: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: () => true, // FULLSCREEN context
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
registerHost: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../generator-panel', () => ({
|
||||||
|
openGeneratorPanel: vi.fn(),
|
||||||
|
closeGeneratorPanel: vi.fn(),
|
||||||
|
isGeneratorPanelOpen: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { renderForm } from '../login';
|
||||||
|
|
||||||
|
describe('popout-to-tab button (fullscreen context)', () => {
|
||||||
|
beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });
|
||||||
|
|
||||||
|
it('does NOT render the popout button when isInTab() is true', () => {
|
||||||
|
renderForm(document.getElementById('app')!, 'add', null);
|
||||||
|
expect(document.getElementById('popout-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify the fullscreen test fails**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/popout-fullscreen.test.ts`
|
||||||
|
Expected: FAIL — popout button is currently rendered unconditionally.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate the popout button in `login.ts`**
|
||||||
|
|
||||||
|
In `extension/src/popup/components/types/login.ts`, find line 249:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Repeat for the other seven files**
|
||||||
|
|
||||||
|
Apply the same conditional wrap to each remaining popout button site. For each, the surrounding context is `<button class="btn" id="popout-btn" title="Open in tab">⤴</button>` — wrap that single line with the ternary.
|
||||||
|
|
||||||
|
For `extension/src/popup/components/item-form.ts:61` (the type-selection screen's popout button), use the same pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `isInTab` is not already imported in a given file, add it to the existing import from `../../../shared/state` (or `../../shared/state` for `item-form.ts`).
|
||||||
|
|
||||||
|
After editing each file, also remove or guard the corresponding `document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);` line — or leave it as-is since `getElementById` returns `null` and the optional-chain handles it. **Leave the listener wiring untouched** to keep the diff minimal; it's a no-op when the button isn't present.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run all popout tests + full suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension
|
||||||
|
./node_modules/.bin/vitest run 2>&1 | tail -8
|
||||||
|
```
|
||||||
|
Expected: all tests pass, including both `popout-button` and `popout-fullscreen` cases.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build to verify**
|
||||||
|
|
||||||
|
Run: `cd /home/alee/Sources/relicario/extension && ./node_modules/.bin/webpack --mode production 2>&1 | tail -5`
|
||||||
|
Expected: `compiled with 2 warnings`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/popup/
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "feat(ext/popup): hide popout-to-tab button in fullscreen forms
|
||||||
|
|
||||||
|
The ⤴ popout button is meaningless when the form is already in
|
||||||
|
vault.html — gate it on !isInTab(). Affects all seven type forms plus
|
||||||
|
the type-selection screen. Regression tests cover both popup (button
|
||||||
|
present) and fullscreen (button absent) contexts.
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 7.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Static "esc to cancel" subtitle in fullscreen forms
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: same eight files as Task 7 (header markup region, ~3-4 lines above the popout button site)
|
||||||
|
- Modify: `extension/src/popup/styles.css` (one new CSS class — shared, since the fullscreen inherits popup styles via vault's own stylesheet only loading vault.css)
|
||||||
|
- Modify: `extension/src/vault/vault.css` (one new CSS class)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `.form-subtitle` CSS class to popup/styles.css**
|
||||||
|
|
||||||
|
Append to `extension/src/popup/styles.css` (anywhere — group near `.muted`):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.form-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the same class to vault.css**
|
||||||
|
|
||||||
|
Append the **identical** `.form-subtitle` rule to `extension/src/vault/vault.css`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write a test for the subtitle in fullscreen context**
|
||||||
|
|
||||||
|
Append to `extension/src/popup/components/types/__tests__/popout-fullscreen.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('form subtitle (fullscreen context)', () => {
|
||||||
|
beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });
|
||||||
|
|
||||||
|
it('renders "esc to cancel" subtitle in the login form header', () => {
|
||||||
|
renderForm(document.getElementById('app')!, 'add', null);
|
||||||
|
const subtitle = document.querySelector('.form-subtitle');
|
||||||
|
expect(subtitle).not.toBeNull();
|
||||||
|
expect(subtitle?.textContent).toContain('esc to cancel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
And add a *negative* test in `required-pill.test.ts` (popup context):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('form subtitle (popup context)', () => {
|
||||||
|
beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });
|
||||||
|
|
||||||
|
it('does NOT render the "esc to cancel" subtitle in popup context', async () => {
|
||||||
|
const { renderForm } = await import('../login');
|
||||||
|
renderForm(document.getElementById('app')!, 'add', null);
|
||||||
|
expect(document.querySelector('.form-subtitle')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify the fullscreen subtitle test fails**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/popout-fullscreen.test.ts`
|
||||||
|
Expected: FAIL — no `.form-subtitle` element rendered today.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `login.ts` header**
|
||||||
|
|
||||||
|
In `extension/src/popup/components/types/login.ts`, find the header markup (lines 246-250):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||||
|
<div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<div style="display:flex; align-items:center;">
|
||||||
|
<div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
|
||||||
|
</div>
|
||||||
|
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'}
|
||||||
|
```
|
||||||
|
|
||||||
|
(The header's `margin-bottom:16px` moves to the conditional spacer so the subtitle gets to sit right under the title.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the test to verify it passes for login**
|
||||||
|
|
||||||
|
Run: `cd extension && ./node_modules/.bin/vitest run src/popup/components/types/__tests__/popout-fullscreen.test.ts src/popup/components/types/__tests__/required-pill.test.ts`
|
||||||
|
Expected: PASS — both fullscreen and popup variants of the subtitle test.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Repeat for the remaining six type forms**
|
||||||
|
|
||||||
|
Apply the same header restructuring to each of:
|
||||||
|
- `card.ts` (around line 179)
|
||||||
|
- `document.ts` (around line 90)
|
||||||
|
- `identity.ts` (around line 139)
|
||||||
|
- `key.ts` (around line 128)
|
||||||
|
- `secure-note.ts` (around line 117)
|
||||||
|
- `totp.ts` (around line 218)
|
||||||
|
|
||||||
|
For each, find the existing header `<div>` block that contains the title + popout button, and add the subtitle line below it using the same conditional pattern. The title text differs per type ("new identity" / "new card" etc.) — preserve whatever the current expression is.
|
||||||
|
|
||||||
|
For `extension/src/popup/components/item-form.ts` (the type-selection screen), apply the same pattern around line 60-63.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the full suite + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension
|
||||||
|
./node_modules/.bin/vitest run 2>&1 | tail -5
|
||||||
|
./node_modules/.bin/webpack --mode production 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
Expected: all tests pass; webpack compiles with 2 warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /home/alee/Sources/relicario add extension/src/popup/ extension/src/vault/
|
||||||
|
git -C /home/alee/Sources/relicario commit -m "feat(ext): static 'esc to cancel' subtitle in fullscreen form headers
|
||||||
|
|
||||||
|
All seven type forms plus the type-selection screen now show a small
|
||||||
|
'esc to cancel' subtitle under the heading when rendered in the
|
||||||
|
fullscreen vault tab (isInTab() === true). The subtitle is suppressed
|
||||||
|
in the popup, where esc has the more general meaning of closing the
|
||||||
|
popup. .form-subtitle class is shared between popup and vault
|
||||||
|
stylesheets so future hooks can reuse it.
|
||||||
|
|
||||||
|
Dynamic dirty-state ('unsaved · esc to cancel') wiring is deferred to
|
||||||
|
Phase 3 (unsaved-changes guard).
|
||||||
|
|
||||||
|
Plan 2026-04-30 fullscreen UX phase 1 task 8.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] **Run the full extension test suite one more time**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension
|
||||||
|
./node_modules/.bin/vitest run 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
Expected: all tests pass (count = previous baseline + the new tests added by this plan).
|
||||||
|
|
||||||
|
- [ ] **Build all variants**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension
|
||||||
|
./node_modules/.bin/webpack --mode production 2>&1 | tail -5
|
||||||
|
./node_modules/.bin/webpack --config webpack.firefox.config.js --mode production 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
Expected: both compile with 2 warnings.
|
||||||
|
|
||||||
|
- [ ] **Manual smoke test**
|
||||||
|
|
||||||
|
Load the unpacked extension in Chrome:
|
||||||
|
1. Open the popup: confirm sidebar settings panel shows `▦ trash` / `⌬ devices` (no emoji), required pill on title fields, focus ring is amber.
|
||||||
|
2. Open vault.html: confirm sidebar shows `▦ trash · ⌬ devices · ⚙ settings · ⏻ lock`, no popout button on the form header, "esc to cancel" subtitle visible under "new login".
|
||||||
|
3. Tab through fields with keyboard: confirm focus ring renders consistently.
|
||||||
|
|
||||||
|
(If anything looks off, the symptom is almost certainly a CSS specificity issue — vault.css may need an `!important` or scoped selector. Note the issue and fix in a follow-up commit.)
|
||||||
804
docs/superpowers/plans/2026-05-01-password-coloring.md
Normal file
@@ -0,0 +1,804 @@
|
|||||||
|
# Password Display Character-Class Coloring — 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:** Color revealed passwords in the extension UI by character class (digits, symbols, letters), defaulting to digits-blue / symbols-red / letters-inherit, with user-configurable colors persisted in `chrome.storage.sync`.
|
||||||
|
|
||||||
|
**Architecture:** A single pure utility `colorizePassword(text)` that returns a `DocumentFragment` of class-named `<span>` runs. CSS rules in the existing extension stylesheet(s) bind those classes to CSS custom properties (`--relicario-pwd-digit-color`, `--relicario-pwd-symbol-color`). User overrides are stored in `chrome.storage.sync` and applied on popup/vault startup by setting the custom properties on `document.documentElement`. All four password-revealing surfaces (popup field-history viewer, popup item detail, fullscreen item detail, generator preview) call the same utility.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Vitest with JSDOM for unit tests, existing `chrome.storage.sync` plumbing in the extension, existing settings UI patterns in `extension/src/popup/components/settings*.ts`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-01-password-coloring-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Created
|
||||||
|
|
||||||
|
- `extension/src/shared/password-coloring.ts` — pure `colorizePassword()` utility + class-name constants.
|
||||||
|
- `extension/src/shared/__tests__/password-coloring.test.ts` — Vitest unit tests for the utility.
|
||||||
|
- `extension/src/shared/color-scheme.ts` — read/write/apply helpers for the user's stored color scheme.
|
||||||
|
- `extension/src/shared/__tests__/color-scheme.test.ts` — Vitest unit tests for storage round-trip + apply.
|
||||||
|
|
||||||
|
(If `extension/src/shared/` does not exist, create it. Otherwise place under whatever the extension's existing shared/utility directory is — match the established convention.)
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
- The popup stylesheet (`extension/src/popup/styles.css` and any vault stylesheet): add `:root` defaults + `.pwd-digit/.pwd-symbol/.pwd-letter` rules.
|
||||||
|
- `extension/src/popup/components/field-history.ts:72` — replace text-content assignment with `colorizePassword()` fragment.
|
||||||
|
- The popup's vault item detail component (find via `grep -n "password.*reveal\|passwordCell" extension/src/popup/`).
|
||||||
|
- `extension/src/vault/` item-detail component — same change, fullscreen surface.
|
||||||
|
- The generator preview component — same change.
|
||||||
|
- The popup's bootstrap (`extension/src/popup/popup.ts` or `index.ts`) — call `applyColorScheme()` once at startup.
|
||||||
|
- The vault's bootstrap (`extension/src/vault/vault.ts`) — same `applyColorScheme()` call.
|
||||||
|
- A settings page component — add the Display section with two color pickers, preview swatch, reset button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Core utility
|
||||||
|
|
||||||
|
### Task 1: `colorizePassword()` pure utility
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `extension/src/shared/password-coloring.ts`
|
||||||
|
- Create: `extension/src/shared/__tests__/password-coloring.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`extension/src/shared/__tests__/password-coloring.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring';
|
||||||
|
|
||||||
|
describe('colorizePassword', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><body></body>');
|
||||||
|
(global as any).document = dom.window.document;
|
||||||
|
});
|
||||||
|
|
||||||
|
function classes(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.className);
|
||||||
|
}
|
||||||
|
function texts(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns empty fragment for empty input', () => {
|
||||||
|
const frag = colorizePassword('');
|
||||||
|
expect(frag.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a mixed-class run', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-letters produces a single letter span', () => {
|
||||||
|
const frag = colorizePassword('passwd');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['passwd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-digits produces a single digit span', () => {
|
||||||
|
const frag = colorizePassword('123456');
|
||||||
|
expect(classes(frag)).toEqual([PWD_DIGIT]);
|
||||||
|
expect(texts(frag)).toEqual(['123456']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-symbols produces a single symbol span', () => {
|
||||||
|
const frag = colorizePassword('!@#$%^');
|
||||||
|
expect(classes(frag)).toEqual([PWD_SYMBOL]);
|
||||||
|
expect(texts(frag)).toEqual(['!@#$%^']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies unicode letters as letters', () => {
|
||||||
|
const frag = colorizePassword('áñü');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies whitespace as symbol', () => {
|
||||||
|
const frag = colorizePassword('a b');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['a', ' ', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('representative password snapshot: aB3$xY7&_!', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY7&_!');
|
||||||
|
expect(classes(frag)).toEqual([
|
||||||
|
PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL,
|
||||||
|
]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect compile failure (module missing)**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run test -- password-coloring
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Cannot find module '../password-coloring'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the utility**
|
||||||
|
|
||||||
|
`extension/src/shared/password-coloring.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const PWD_DIGIT = 'pwd-digit';
|
||||||
|
export const PWD_SYMBOL = 'pwd-symbol';
|
||||||
|
export const PWD_LETTER = 'pwd-letter';
|
||||||
|
|
||||||
|
type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER;
|
||||||
|
|
||||||
|
function classify(ch: string): Class {
|
||||||
|
if (/^\d$/.test(ch)) return PWD_DIGIT;
|
||||||
|
if (/^\p{L}$/u.test(ch)) return PWD_LETTER;
|
||||||
|
return PWD_SYMBOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split `text` into runs of same-class codepoints and return a DocumentFragment
|
||||||
|
* of class-named <span> nodes (one span per run). Returns an empty fragment
|
||||||
|
* for empty input.
|
||||||
|
*
|
||||||
|
* Pure: does not mutate any DOM outside the returned fragment, does not perform
|
||||||
|
* I/O. Safe to call on every render.
|
||||||
|
*/
|
||||||
|
export function colorizePassword(text: string): DocumentFragment {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (text.length === 0) return frag;
|
||||||
|
|
||||||
|
// Iterate by codepoint so unicode letters classify correctly.
|
||||||
|
const codepoints = Array.from(text);
|
||||||
|
let runStart = 0;
|
||||||
|
let runClass = classify(codepoints[0]);
|
||||||
|
|
||||||
|
for (let i = 1; i <= codepoints.length; i++) {
|
||||||
|
const c = i < codepoints.length ? classify(codepoints[i]) : null;
|
||||||
|
if (c !== runClass) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = runClass;
|
||||||
|
span.textContent = codepoints.slice(runStart, i).join('');
|
||||||
|
frag.appendChild(span);
|
||||||
|
if (c !== null) {
|
||||||
|
runStart = i;
|
||||||
|
runClass = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — expect pass**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run test -- password-coloring
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 8 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/shared/password-coloring.ts extension/src/shared/__tests__/password-coloring.test.ts
|
||||||
|
git commit -m "feat(ext/shared): add colorizePassword utility"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — Color scheme storage + apply
|
||||||
|
|
||||||
|
### Task 2: `applyColorScheme()` + storage round-trip
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `extension/src/shared/color-scheme.ts`
|
||||||
|
- Create: `extension/src/shared/__tests__/color-scheme.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
`extension/src/shared/__tests__/color-scheme.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../color-scheme';
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: any = {}) {
|
||||||
|
const store = { ...initial };
|
||||||
|
(global as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
sync: {
|
||||||
|
get: vi.fn((key: string) => Promise.resolve(
|
||||||
|
key in store ? { [key]: store[key] } : {})),
|
||||||
|
set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||||
|
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('color-scheme storage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><body></body>');
|
||||||
|
(global as any).document = dom.window.document;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns defaults when storage is empty', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns stored values when present', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' },
|
||||||
|
});
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe('#123456');
|
||||||
|
expect(scheme.symbol_color).toBe('#abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save round-trips', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset removes the storage key', async () => {
|
||||||
|
const store = mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#000', symbol_color: '#fff' },
|
||||||
|
});
|
||||||
|
await resetColorScheme();
|
||||||
|
expect(store.password_display_scheme).toBeUndefined();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('apply sets CSS custom properties on document.documentElement', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' },
|
||||||
|
});
|
||||||
|
await applyColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe');
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save rejects malformed hex values', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' }))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect compile failure**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run test -- color-scheme
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: missing module.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
`extension/src/shared/color-scheme.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const DEFAULT_DIGIT_COLOR = '#2563eb';
|
||||||
|
export const DEFAULT_SYMBOL_COLOR = '#dc2626';
|
||||||
|
const STORAGE_KEY = 'password_display_scheme';
|
||||||
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
export interface ColorScheme {
|
||||||
|
digit_color: string;
|
||||||
|
symbol_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEME: ColorScheme = {
|
||||||
|
digit_color: DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValid(s: ColorScheme): boolean {
|
||||||
|
return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadColorScheme(): Promise<ColorScheme> {
|
||||||
|
const result = await chrome.storage.sync.get(STORAGE_KEY);
|
||||||
|
const stored = result[STORAGE_KEY] as Partial<ColorScheme> | undefined;
|
||||||
|
if (!stored) return { ...DEFAULT_SCHEME };
|
||||||
|
const merged: ColorScheme = {
|
||||||
|
digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color)
|
||||||
|
? stored.digit_color : DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color)
|
||||||
|
? stored.symbol_color : DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveColorScheme(scheme: ColorScheme): Promise<void> {
|
||||||
|
if (!isValid(scheme)) {
|
||||||
|
throw new Error('Invalid color values; expected #rrggbb hex strings.');
|
||||||
|
}
|
||||||
|
await chrome.storage.sync.set({ [STORAGE_KEY]: scheme });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetColorScheme(): Promise<void> {
|
||||||
|
await chrome.storage.sync.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the user's stored scheme (or defaults) and apply the colors as inline
|
||||||
|
* CSS custom properties on `document.documentElement`. Idempotent — safe to
|
||||||
|
* call on every popup/vault boot, and from a chrome.storage.onChanged handler
|
||||||
|
* to react to live edits from another open extension surface.
|
||||||
|
*/
|
||||||
|
export async function applyColorScheme(): Promise<void> {
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
root.setProperty('--relicario-pwd-digit-color', scheme.digit_color);
|
||||||
|
root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — expect pass**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run test -- color-scheme
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 6 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/shared/color-scheme.ts extension/src/shared/__tests__/color-scheme.test.ts
|
||||||
|
git commit -m "feat(ext/shared): color-scheme storage + applyColorScheme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C — Stylesheet integration
|
||||||
|
|
||||||
|
### Task 3: Add CSS rules + custom-property defaults
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/styles.css`
|
||||||
|
- Modify: `extension/src/vault/vault.css` (and any other extension stylesheet that styles password reveal cells)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the rules**
|
||||||
|
|
||||||
|
Append to each stylesheet (or to a single shared partial if the build supports CSS imports):
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--relicario-pwd-digit-color: #2563eb;
|
||||||
|
--relicario-pwd-symbol-color: #dc2626;
|
||||||
|
}
|
||||||
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.pwd-letter { color: inherit; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the extension**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean build, no CSS errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/popup/styles.css extension/src/vault/vault.css
|
||||||
|
git commit -m "style(ext): add password-coloring CSS rules + custom property defaults"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D — Wire into reveal surfaces
|
||||||
|
|
||||||
|
### Task 4: Field-history viewer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/components/field-history.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate the text-content assignment**
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -n "history-entry__value\|displayValue" extension/src/popup/components/field-history.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The line near 72 reads roughly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is template-string interpolation, so `displayValue` is escaped HTML. The change requires switching from a string-template render to an imperative DOM patch (since `colorizePassword()` returns DOM, not HTML strings).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the render to imperatively set content**
|
||||||
|
|
||||||
|
After the template renders the entry's outer markup, query the `.history-entry__value` element for revealed entries and replace its `textContent` with `colorizePassword(value)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
|
// existing render ...
|
||||||
|
|
||||||
|
container.querySelectorAll('.history-entry__value.revealed').forEach((el, idx) => {
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(colorizePassword(revealedValues[idx]));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
(`revealedValues` here stands in for whatever array of revealed-entry values was already computed; adapt to actual variable names.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update or add a test for this surface**
|
||||||
|
|
||||||
|
If `extension/src/popup/components/__tests__/field-history.test.ts` exists, add a case asserting that a revealed password's DOM contains `.pwd-*` spans. Otherwise just verify by running the existing test suite + a manual check.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('revealed entry colorizes by character class', () => {
|
||||||
|
const dom = render(/* item with password "aB3$" in field history, revealed */);
|
||||||
|
const revealed = dom.querySelector('.history-entry__value.revealed')!;
|
||||||
|
expect(revealed.querySelector('.pwd-digit')?.textContent).toBe('3');
|
||||||
|
expect(revealed.querySelector('.pwd-symbol')?.textContent).toBe('$');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests + manual visual check**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS. Then build and load the extension to verify a revealed password in the field-history viewer is colored.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/popup/components/field-history.ts \
|
||||||
|
extension/src/popup/components/__tests__/field-history.test.ts
|
||||||
|
git commit -m "feat(ext/popup/field-history): colorize revealed password entries"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Popup vault item detail (password reveal)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the popup component that renders the password field's revealed value (find via `grep -rn "field.*Password\|FieldKind.Password\|reveal" extension/src/popup/components/`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find the surface**
|
||||||
|
|
||||||
|
Read the matched files and identify the line(s) that set the password text when revealed. The likely shape is a function `renderField(field)` with a branch on `field.kind === FieldKind.Password`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the same imperative pattern**
|
||||||
|
|
||||||
|
Replace whatever currently sets the password's text content with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
|
passwordValueEl.textContent = '';
|
||||||
|
if (revealed) {
|
||||||
|
passwordValueEl.appendChild(colorizePassword(field.value));
|
||||||
|
} else {
|
||||||
|
passwordValueEl.textContent = '••••••••';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests + manual check**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Build, load, reveal a password — confirm coloring.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/popup/components/
|
||||||
|
git commit -m "feat(ext/popup/item-detail): colorize revealed password field"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Fullscreen vault item detail
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the equivalent component under `extension/src/vault/`
|
||||||
|
|
||||||
|
The fullscreen vault is currently undergoing a Phase 1 redesign (see `9ed7e7c` and the Phase 1 plan in `docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md`). Coordinate with that work — if the password-reveal surface is in active flux, land this change after Phase 1 settles, or fold it into Phase 2 if the user is doing that work themselves.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find the fullscreen reveal surface**
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -rn "FieldKind.Password\|password.*reveal\|reveal.*password" extension/src/vault/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the same pattern as Task 5**
|
||||||
|
|
||||||
|
Same code shape. Different file.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests + manual check**
|
||||||
|
|
||||||
|
Open the fullscreen vault, reveal a password, confirm coloring.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/vault/
|
||||||
|
git commit -m "feat(ext/vault): colorize revealed password field in fullscreen view"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Generator preview
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the generator component (find via `grep -rn "generate_password\|generator.*preview" extension/src/`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find the surface**
|
||||||
|
|
||||||
|
The generator likely has a live preview element that updates as the user adjusts character-class toggles, length, etc.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the imperative pattern**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
|
previewEl.textContent = '';
|
||||||
|
previewEl.appendChild(colorizePassword(generatedPassword));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests + manual check**
|
||||||
|
|
||||||
|
Open the generator, click roll/regenerate a few times — confirm the preview updates with coloring intact.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/popup/components/ # or wherever the generator lives
|
||||||
|
git commit -m "feat(ext/generator): colorize live password preview"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase E — Boot wiring
|
||||||
|
|
||||||
|
### Task 8: Call `applyColorScheme()` on popup + vault startup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `extension/src/popup/popup.ts` (or `popup/index.ts` — the popup's bootstrap)
|
||||||
|
- Modify: `extension/src/vault/vault.ts` — the fullscreen vault's bootstrap
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the call in popup boot**
|
||||||
|
|
||||||
|
Near the top of the popup's `init()` / `main()` function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { applyColorScheme } from '../shared/color-scheme';
|
||||||
|
|
||||||
|
await applyColorScheme();
|
||||||
|
```
|
||||||
|
|
||||||
|
The `await` is fine — it runs once per popup open, the storage round-trip is cheap (sub-millisecond).
|
||||||
|
|
||||||
|
Also wire a `chrome.storage.onChanged` listener so live edits from another open extension surface (e.g., the settings page) reflect immediately:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||||
|
void applyColorScheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the call in vault boot**
|
||||||
|
|
||||||
|
Same pattern in the fullscreen vault's bootstrap.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual verification**
|
||||||
|
|
||||||
|
Open both surfaces, edit the colors via the (about-to-exist) settings page, observe the change reflect in real time.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/popup/popup.ts extension/src/vault/vault.ts
|
||||||
|
git commit -m "feat(ext): apply color scheme on popup + vault startup, react to storage changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase F — Settings UI
|
||||||
|
|
||||||
|
### Task 9: Display section in settings with color pickers + preview swatch + reset
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: an existing settings component — best candidate is `extension/src/popup/components/settings.ts` (general settings) or a new dedicated section if settings are split. Read the existing settings layout before deciding.
|
||||||
|
- Test: `extension/src/popup/components/__tests__/settings.test.ts` (extend existing tests)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find the existing settings shape**
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -n "render\|section\|setting" extension/src/popup/components/settings.ts | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Identify the pattern used to render a settings group (likely a `section` builder + child controls).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the Display section**
|
||||||
|
|
||||||
|
Following the existing pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../../shared/color-scheme';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
|
async function renderDisplaySection(parent: HTMLElement) {
|
||||||
|
const section = createSection('Display');
|
||||||
|
parent.appendChild(section);
|
||||||
|
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
|
||||||
|
const digitInput = createColorInput('Digit color', scheme.digit_color);
|
||||||
|
const symbolInput = createColorInput('Symbol color', scheme.symbol_color);
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'color-preview-swatch';
|
||||||
|
|
||||||
|
const SAMPLE = 'Abc123!@#xyz';
|
||||||
|
|
||||||
|
const updateSwatch = () => {
|
||||||
|
swatch.style.setProperty('--relicario-pwd-digit-color', digitInput.value);
|
||||||
|
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolInput.value);
|
||||||
|
swatch.textContent = '';
|
||||||
|
swatch.appendChild(colorizePassword(SAMPLE));
|
||||||
|
};
|
||||||
|
updateSwatch();
|
||||||
|
|
||||||
|
const onChange = async () => {
|
||||||
|
updateSwatch();
|
||||||
|
try {
|
||||||
|
await saveColorScheme({
|
||||||
|
digit_color: digitInput.value, symbol_color: symbolInput.value,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Show inline error; keep current swatch.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
digitInput.addEventListener('change', onChange);
|
||||||
|
symbolInput.addEventListener('change', onChange);
|
||||||
|
|
||||||
|
const resetBtn = document.createElement('button');
|
||||||
|
resetBtn.textContent = 'Reset to defaults';
|
||||||
|
resetBtn.addEventListener('click', async () => {
|
||||||
|
digitInput.value = DEFAULT_DIGIT_COLOR;
|
||||||
|
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
||||||
|
await resetColorScheme();
|
||||||
|
updateSwatch();
|
||||||
|
});
|
||||||
|
|
||||||
|
section.append(digitInput, symbolInput, swatch, resetBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createColorInput(label: string, value: string): HTMLInputElement & { label: string } {
|
||||||
|
// simple <label><input type=color>...
|
||||||
|
const input = document.createElement('input') as HTMLInputElement & { label: string };
|
||||||
|
input.type = 'color';
|
||||||
|
input.value = value;
|
||||||
|
input.label = label;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapt to the existing component-creation idioms — the snippet above is illustrative.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the swatch styling**
|
||||||
|
|
||||||
|
In the popup stylesheet:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.color-preview-swatch {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.color-preview-swatch .pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.color-preview-swatch .pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.color-preview-swatch .pwd-letter { color: inherit; }
|
||||||
|
```
|
||||||
|
|
||||||
|
(The custom properties are scoped to `.color-preview-swatch` itself via `style.setProperty`, so the swatch's preview is independent of the global root scheme — handy for previewing changes without committing them.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add a settings test**
|
||||||
|
|
||||||
|
In `extension/src/popup/components/__tests__/settings.test.ts`, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('Display section round-trips color scheme to storage', async () => {
|
||||||
|
// mock chrome.storage.sync, render settings, change the digit color picker,
|
||||||
|
// assert chrome.storage.sync.set was called with the new value.
|
||||||
|
// (Detailed scaffolding follows the existing tests in this file.)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reset button clears storage and restores swatch defaults', async () => {
|
||||||
|
// render, change colors, click reset, assert chrome.storage.sync.remove
|
||||||
|
// was called and swatch reverts.
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all extension tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd extension && npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add extension/src/popup/components/settings.ts \
|
||||||
|
extension/src/popup/components/__tests__/settings.test.ts \
|
||||||
|
extension/src/popup/styles.css
|
||||||
|
git commit -m "feat(ext/settings): Display section with color pickers + swatch + reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
Spec coverage check:
|
||||||
|
|
||||||
|
- **`colorizePassword` utility, single source of truth:** Task 1.
|
||||||
|
- **Three character classes (digit / symbol / letter), Unicode-letter classification:** Task 1.
|
||||||
|
- **CSS rules with custom properties + defaults:** Task 3.
|
||||||
|
- **Storage shape (`password_display_scheme`), default fallbacks, hex validation:** Task 2.
|
||||||
|
- **`applyColorScheme()` boot step on popup + vault:** Task 8.
|
||||||
|
- **Live updates via `chrome.storage.onChanged`:** Task 8.
|
||||||
|
- **Wire into field-history viewer:** Task 4.
|
||||||
|
- **Wire into popup item detail:** Task 5.
|
||||||
|
- **Wire into fullscreen item detail:** Task 6.
|
||||||
|
- **Wire into generator preview:** Task 7.
|
||||||
|
- **Settings UI with pickers + preview swatch + reset:** Task 9.
|
||||||
|
- **WCAG AA contrast warning:** spec says non-blocking; this is a small follow-up not gated by anything in this plan, so it is **not** included as a separate task. Either add a tiny inline contrast check in Task 9's `onChange` (left as an exercise — the contrast formula is `(L1 + 0.05) / (L2 + 0.05)`; show a `.contrast-warning` element when below 4.5) or open a follow-up issue.
|
||||||
|
|
||||||
|
No placeholders. No type drift (the `ColorScheme` interface and `PWD_*` constants are referenced consistently).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordination note
|
||||||
|
|
||||||
|
The fullscreen UX redesign (Phase 1, recently merged in `87e63c2`) is in flight. **Task 6** (fullscreen reveal surface) touches code that may also be touched by ongoing UX work — coordinate with the user before landing it. Tasks 1–5, 7–9 are independent of fullscreen work and can land standalone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Handoff
|
||||||
|
|
||||||
|
Plan complete and saved to `docs/superpowers/plans/2026-05-01-password-coloring.md`.
|
||||||
|
|
||||||
|
When ready to execute, the user's preference per `feedback_subagent_default` is **subagent-driven**: a fresh subagent per task, with two-stage review between tasks.
|
||||||
1791
docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md
Normal file
@@ -1,10 +1,10 @@
|
|||||||
# relicario — 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.
|
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
|
## Overview
|
||||||
|
|
||||||
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.
|
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.
|
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 | 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. |
|
| 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. |
|
| 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 | relicario 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
|
### Out of scope
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ With a 4-word diceware passphrase (~51 bits) and Argon2id at 64 MiB, brute-force
|
|||||||
Compared to competitors:
|
Compared to competitors:
|
||||||
- LastPass/Bitwarden: server breach exposes ~40-60 bits (master password only)
|
- LastPass/Bitwarden: server breach exposes ~40-60 bits (master password only)
|
||||||
- 1Password: server breach exposes password + 128-bit Secret Key
|
- 1Password: server breach exposes password + 128-bit Secret Key
|
||||||
- relicario: server breach exposes password + 256-bit image_secret
|
- Relicario: server breach exposes password + 256-bit image_secret
|
||||||
|
|
||||||
### Authenticated encryption
|
### Authenticated encryption
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario — Credential Capture Design
|
# 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.
|
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.
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ A fixed-position bar at the top of the page, injected into the DOM:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ relicario: Save login for github.com? (alee) [Save] [Never] [✕] │
|
│ Relicario: Save login for github.com? (alee) [Save] [Never] [✕] │
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ A floating element in the bottom-right corner:
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────┐
|
┌─────────────────────────────────┐
|
||||||
│ relicario │
|
│ Relicario │
|
||||||
│ Save login for github.com? │
|
│ Save login for github.com? │
|
||||||
│ alee │
|
│ alee │
|
||||||
│ [Save] [Never] [✕] │
|
│ [Save] [Never] [✕] │
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario — Firefox Extension Port Design
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# relicario — Standalone Vault Initialization Wizard Design
|
# 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.
|
A browser-based wizard that guides new users through creating a 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
|
## Scope
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@ Two things happen:
|
|||||||
- Show warning: "Keep this image safe. You need it alongside your passphrase to unlock the vault. Store it somewhere you won't lose it."
|
- 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):**
|
**Push config to extension (if available):**
|
||||||
- Try to detect the relicario extension via `chrome.runtime.sendMessage` with a `get_setup_state` message
|
- 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 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.)
|
- 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
|
## WASM Crate Change
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# relicario — WASM + Chrome MV3 Extension Design
|
# 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.
|
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
|
## Scope
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ No shadow DOM traversal. No heuristic scoring. No iframe inspection. If the form
|
|||||||
### 2. Field Icon Injection
|
### 2. Field Icon Injection
|
||||||
|
|
||||||
When a password field is detected:
|
When a password field is detected:
|
||||||
- Small relicario icon (16x16, inline SVG) appears at the right edge of the password field
|
- 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
|
- Click triggers: send page URL to service worker → get matching entries
|
||||||
- Single match: fill immediately
|
- Single match: fill immediately
|
||||||
- Multiple matches: show inline picker (small dropdown below the icon)
|
- Multiple matches: show inline picker (small dropdown below the icon)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# relicario — Typed Item Data Model Design
|
# 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario — Extension Plan 1C-α (Foundation) Design
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# relicario — Extension Plan 1C-β₁ (Typed-Item Forms) Design
|
# Relicario — Extension Plan 1C-β₁ (Typed-Item Forms) Design
|
||||||
|
|
||||||
Second of three sub-plans porting the extension to the typed-item core. 1C-α (foundation) shipped Login-parity; 1C-β₁ adds the **other 5 typed-item forms** so the extension can daily-drive every typed item the Rust core knows about (except Document, deferred to γ for attachment dependencies). Custom-fields editor, vault-settings view, and advanced generator UI move to **β₂**.
|
Second of three sub-plans porting the extension to the typed-item core. 1C-α (foundation) shipped Login-parity; 1C-β₁ adds the **other 5 typed-item forms** so the extension can daily-drive every typed item the Rust core knows about (except Document, deferred to γ for attachment dependencies). Custom-fields editor, vault-settings view, and advanced generator UI move to **β₂**.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,731 @@
|
|||||||
|
# Relicario — Extension Plan 1C-β₂ (Custom Fields + Settings + Generator UI) Design
|
||||||
|
|
||||||
|
Third of three β sub-plans porting the extension to the typed-item core. 1C-α shipped the security architecture + Login parity; 1C-β₁ added the 5 remaining typed-item forms; **1C-β₂** (this spec) adds the cross-cutting UI surfaces: custom fields editor, full vault-settings view, and an inline generator popover.
|
||||||
|
|
||||||
|
Reference specs: `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (α, commits `a1d733d` + `ad6d8af`), `docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md` (β₁, commit `1b51b7d`). Both implementations merged to main: α at `2b83105` (tag `plan-1c-alpha-complete`), β₁ at `81fbe13` (tag `plan-1c-beta1-complete`).
|
||||||
|
|
||||||
|
## Plan 1C decomposition (final shape)
|
||||||
|
|
||||||
|
| Sub-plan | Status | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| 1C-α | shipped 2026-04-22 | WASM rebuild, typed-item shared TS types, SessionHandle SW, split router with sender checks, closed Shadow DOM content scripts, Login-parity popup, zxcvbn setup gate |
|
||||||
|
| 1C-β₁ | shipped 2026-04-22 | 5 remaining typed-item forms (SecureNote / Identity / Card / Key / Totp) + Rust Steam-Guard alphabet patch; shared field helpers + Login refactor |
|
||||||
|
| **1C-β₂** (this spec) | proposed | Custom-fields editor (Text/Password/Concealed), full VaultSettings view (retention + generator defaults + origin-ack revoke), advanced generator popover |
|
||||||
|
| 1C-γ | proposed | Attachments (with `putBlob` Git-Data-API fallback), Document type, trash view, field-history view, device management, attachment caps UI |
|
||||||
|
|
||||||
|
## Design Decisions (from brainstorming)
|
||||||
|
|
||||||
|
| Question | Decision | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Custom-fields scope | **Tier 1 — Text/Password/Concealed only, no reordering** | The other 8 FieldKinds (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline) each add real UX work; tier 1 covers the "recovery codes, security questions" 90% case. Reordering and additional kinds live in a later polish pass. |
|
||||||
|
| VaultSettings scope | **Retention + generator defaults + origin-ack revoke; skip attachment caps** | Attachment caps govern a feature that doesn't ship until γ. Ship the caps UI alongside the feature. |
|
||||||
|
| Generator UI location | **Inline popover + Settings preview** | One underlying `GeneratorRequest` config, two entry points. Matches 1Password/Bitwarden. "save as default" in the popover updates Settings without forcing the user to navigate. |
|
||||||
|
| Custom-fields edit-view placement | **Collapsible disclosure ("▸ custom sections & fields (N)")** | Most items never grow custom fields; always-visible editor adds clutter for the 90% case. Count-hint on the disclosure gives discoverability without noise. |
|
||||||
|
| Sequencing | **5 slices: detail render → edit render → vault-settings SW (+ generate_passphrase if missing) → generator popover → settings view** | Matches β₁'s cadence. SW plumbing lands before the popover so "save as default" is fully functional the moment the popover ships. |
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In
|
||||||
|
|
||||||
|
- **Custom-fields rendering** (detail view): `Item.sections` rendered below typed rows via a new `renderSections(item, idPrefix)` helper in `fields.ts`. Sections with ≥1 field render a header (named) or thin separator (anonymous). Fields of kind `text` render via `renderRow`; `password`/`concealed` via `renderConcealedRow` with per-section unique IDs.
|
||||||
|
|
||||||
|
- **Custom-fields editor** (edit view): collapsible disclosure ("▸ custom sections & fields (N)") at the bottom of every type's form. Expanded state shows each section's rename/remove buttons, per-field label + value inputs + `×` delete, and per-section `[+ text] [+ password] [+ concealed]` buttons. A `[+ add section]` button at the bottom. Sections have optional names (rename via `prompt()`; clear to make anonymous). Save packs `sectionsDraft` into the outgoing `Item.sections`.
|
||||||
|
|
||||||
|
- **FieldKind support**: `text`, `password`, `concealed` only. `Url` / `Email` / `Phone` / `Date` / `MonthYear` / `Totp` / `Reference` / `Multiline` all remain Rust-core-only (the data model supports them; the popup doesn't render editors for them in β₂).
|
||||||
|
|
||||||
|
- **No reordering**: new fields append to their section's `fields` array; new sections append to `item.sections`. Rendering preserves array order. A future polish pass can add up/down arrows or drag handles.
|
||||||
|
|
||||||
|
- **Full VaultSettings view**: new `popup/components/settings-vault.ts` screen wired to the ⚙ toolbar button (now a tiny picker: device / vault). Covers:
|
||||||
|
- Trash retention (`Days(N)` / `Forever`) via a preset dropdown (Forever / 7 / 30 / 60 / 90 / 180 / 365 / custom days).
|
||||||
|
- Field-history retention (`LastN(N)` / `Days(N)` / `Forever`) via a preset dropdown (Forever / Last 3 / Last 5 / Last 10 / 30 days / 90 days / 365 days / custom).
|
||||||
|
- Generator-default preview with a "configure ▾" button that opens the same generator popover used at form "gen" sites; "save as default" closes the loop.
|
||||||
|
- Origin-ack list (`autofill_origin_acks`) sorted by most-recent first, with per-host revoke buttons.
|
||||||
|
- Save-changes / discard buttons; save disabled until `pendingSettings` differs from `vaultSettings`.
|
||||||
|
|
||||||
|
- **Advanced generator popover**: new `popup/components/generator-popover.ts`. Anchored to the "gen" button; positioned absolutely below. Kind toggle (Random / BIP39). Random knobs: length slider (8-64), 4 char-class checkboxes, symbol-charset toggle (safe_only / extended / custom). BIP39 knobs: word count slider (3-12), separator chip picker (space / `-` / `_` / `.` / `:`), capitalization picker (lower / upper / first-of-each / title). Live preview via `generate_password` / `generate_passphrase` message on 150ms debounce. Four action buttons: `reset to defaults`, `save as default`, `cancel`, `use this value`. Validation: "use this value" disabled when no char class selected for Random kind.
|
||||||
|
|
||||||
|
- **New popup-only messages**: `get_vault_settings` → returns full `VaultSettings`. `update_vault_settings` → writes full `VaultSettings`. Both added to `POPUP_ONLY_TYPES`; not in `SETUP_ALLOWED`. Router test matrix grows by 4 cases (accept from popup × 2, reject from content × 2).
|
||||||
|
|
||||||
|
- **Teardown integration**: every type module's `teardown()` gains `closeGeneratorPopover()`. The collapsible disclosure's expanded-state (`sectionsExpanded: boolean`) is module-scope and reset by `teardown()`.
|
||||||
|
|
||||||
|
### Out (→ γ / later)
|
||||||
|
|
||||||
|
- Reordering (sections or fields-within-section).
|
||||||
|
- Other FieldKind variants (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline).
|
||||||
|
- Attachment caps UI (γ concern, bundled with attachments).
|
||||||
|
- Bulk custom-field operations (delete-many, template, import-from-CSV).
|
||||||
|
- Per-type section templates (e.g., Card auto-creates a "billing address" section).
|
||||||
|
- Item-to-item `Reference` pointers (requires attachment picker).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data flow additions
|
||||||
|
|
||||||
|
1. **Custom fields**: already present end-to-end — the Rust core's `Item.sections: Vec<Section>` + `Section.fields: Vec<Field>` + `Field.value: FieldValue` data model is complete. β₁'s save paths already pass `sections: existing?.sections ?? []` through. β₂ just grows the UI to produce and consume that shape. No SW message changes.
|
||||||
|
|
||||||
|
2. **Vault settings**: α plumbed `fetchAndDecryptSettings` / `encryptAndWriteSettings` through `service-worker/vault.ts` for the autofill origin-ack writes. β₂ exposes the full `VaultSettings` object via two new popup-only messages. No new Rust or WASM work.
|
||||||
|
|
||||||
|
3. **Generator popover**: already has all the plumbing it needs — α's `generate_password` / `generate_passphrase` messages accept an arbitrary `GeneratorRequest` and route to the WASM layer. β₂ just wires a UI.
|
||||||
|
|
||||||
|
### Module boundaries
|
||||||
|
|
||||||
|
```
|
||||||
|
popup/components/
|
||||||
|
fields.ts (extended) — + renderSections, renderSectionsEditor,
|
||||||
|
wireSectionsEditor, generateFieldId
|
||||||
|
generator-popover.ts (new) — openGeneratorPopover, closeGeneratorPopover
|
||||||
|
settings-vault.ts (new) — renderVaultSettings
|
||||||
|
item-list.ts (edit) — ⚙ toolbar button → device/vault picker
|
||||||
|
types/login.ts (edit) — + sections tail in renderDetail;
|
||||||
|
+ disclosure in renderForm;
|
||||||
|
+ generator popover wire on "gen" button;
|
||||||
|
+ closeGeneratorPopover in teardown
|
||||||
|
types/{secure-note,identity,card,key,totp}.ts (edit) — same integration pattern
|
||||||
|
|
||||||
|
service-worker/
|
||||||
|
router/popup-only.ts (edit) — + get_vault_settings, update_vault_settings
|
||||||
|
|
||||||
|
shared/
|
||||||
|
messages.ts (edit) — + 2 new PopupMessage variants, added to POPUP_ONLY_TYPES
|
||||||
|
types.ts (unchanged)
|
||||||
|
|
||||||
|
popup/popup.ts (edit) — + vaultSettings + generatorDefaults in PopupState;
|
||||||
|
+ fetch after unlock; + settings-vault view route
|
||||||
|
```
|
||||||
|
|
||||||
|
### PopupState additions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vaultSettings: VaultSettings | null; // cached on unlock; refreshed on save
|
||||||
|
generatorDefaults: GeneratorRequest | null; // derived from vaultSettings.generator_defaults
|
||||||
|
view: 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';
|
||||||
|
```
|
||||||
|
|
||||||
|
The `'settings-vault'` view routes to the new `renderVaultSettings`.
|
||||||
|
|
||||||
|
## Slice 1 — Custom-fields detail rendering
|
||||||
|
|
||||||
|
### `fields.ts#renderSections`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function renderSections(item: Item, idPrefix: string): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Walks `item.sections`. For each section with ≥1 field:
|
||||||
|
- If `section.name` truthy: emit `<div class="section-header">{escaped name}</div>`
|
||||||
|
- Else (anonymous): emit `<hr class="section-separator">`
|
||||||
|
- For each field:
|
||||||
|
- `field.value.kind === 'text'` → `renderRow({ label: field.label, value: field.value.value, copyable: true })`
|
||||||
|
- `field.value.kind === 'password'` / `'concealed'` → `renderConcealedRow({ id: `${idPrefix}-s${sectionIdx}-f${fieldIdx}`, label: field.label, value: field.value.value })`
|
||||||
|
- Other kinds: silently skip in β₂ (the Rust core may carry other-kind fields from the CLI; we render what we support).
|
||||||
|
|
||||||
|
### Per-type integration
|
||||||
|
|
||||||
|
Every type module's `renderDetail` gets a call to `renderSections` between typed rows and action buttons:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
${/* signature block + typed rows */}
|
||||||
|
${renderSections(item, '<type>')} // ← added
|
||||||
|
${/* form-actions */}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
`wireFieldHandlers(app)` call already at the bottom of each type module picks up the new reveal/copy buttons in custom-field rows.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`types/__tests__/sections-render.test.ts`:
|
||||||
|
- Empty `item.sections` → `renderSections` returns empty string.
|
||||||
|
- One named section with 2 text fields → contains the section name + both field labels + both values as visible text.
|
||||||
|
- Mixed text + password fields → password value concealed (not in visible DOM text); has reveal button.
|
||||||
|
- Anonymous section → separator HR, no name header.
|
||||||
|
- Unsupported kind (e.g., a `date` field from the CLI) → silently skipped, no error.
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
.section-header {
|
||||||
|
margin-top: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.section-separator { margin: 10px 0 4px; border: 0; border-top: 1px solid #21262d; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slice 2 — Custom-fields edit rendering
|
||||||
|
|
||||||
|
### `fields.ts#renderSectionsEditor` + `wireSectionsEditor`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function renderSectionsEditor(sections: Section[], expanded: boolean): string;
|
||||||
|
|
||||||
|
/// Wire handlers for the editor's interactive elements. Mutations to
|
||||||
|
/// `sectionsDraft` are reflected by `rerender()` — callers implement
|
||||||
|
/// rerender by re-running `renderSectionsEditor` + inserting it back
|
||||||
|
/// into the disclosure's body element.
|
||||||
|
export function wireSectionsEditor(
|
||||||
|
scope: HTMLElement,
|
||||||
|
sectionsDraft: Section[],
|
||||||
|
rerender: () => void,
|
||||||
|
): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout (expanded state)
|
||||||
|
|
||||||
|
```
|
||||||
|
▾ custom sections & fields (2 sections, 5 fields)
|
||||||
|
|
||||||
|
── recovery codes ────── [rename] [× remove section]
|
||||||
|
[label_________] [value_________________] [×]
|
||||||
|
[label_________] [value_________________] [×]
|
||||||
|
[+ text] [+ password] [+ concealed]
|
||||||
|
|
||||||
|
── (anonymous) ───────── [rename] [× remove section]
|
||||||
|
[label_________] [value_________________] [×]
|
||||||
|
[+ text] [+ password] [+ concealed]
|
||||||
|
|
||||||
|
[+ add section]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `generateFieldId`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/// Client-side 16-char hex FieldId. Uses crypto.getRandomValues for
|
||||||
|
/// 8 random bytes; matches the wire-format requirement. No SW round-trip.
|
||||||
|
export function generateFieldId(): string {
|
||||||
|
const bytes = new Uint8Array(8);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutations
|
||||||
|
|
||||||
|
- **Add section**: `sectionsDraft.push({ name: undefined, fields: [] })`; rerender.
|
||||||
|
- **Rename section**: `prompt('Section name (empty for none):', section.name ?? '')`; set `sectionsDraft[i].name = result.trim() || undefined`; rerender.
|
||||||
|
- **Remove section**: `confirm('Remove section ...?')`; `sectionsDraft.splice(i, 1)`; rerender.
|
||||||
|
- **Add field** (kind K): `sectionsDraft[i].fields.push(makeField(K))`; rerender. Helper:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function makeField(kind: 'text' | 'password' | 'concealed'): Field {
|
||||||
|
const hidden = kind !== 'text';
|
||||||
|
return {
|
||||||
|
id: generateFieldId(),
|
||||||
|
label: 'new field',
|
||||||
|
kind,
|
||||||
|
value: { kind, value: '' } as FieldValue,
|
||||||
|
hidden_by_default: hidden,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Remove field**: `sectionsDraft[i].fields.splice(j, 1)`; rerender.
|
||||||
|
- **Edit field label**: `input` event on label input mutates `sectionsDraft[i].fields[j].label` in place. No rerender (would steal focus).
|
||||||
|
- **Edit field value**: `input` event mutates `sectionsDraft[i].fields[j].value.value` in place. No rerender.
|
||||||
|
|
||||||
|
### Per-type form integration
|
||||||
|
|
||||||
|
Each of the 6 type modules (`types/<x>.ts`):
|
||||||
|
|
||||||
|
1. At the top of `renderForm`, initialize a local `sectionsDraft: Section[] = existing?.sections.map(deepClone) ?? []` (deep clone so cancel doesn't mutate the pre-existing item).
|
||||||
|
2. Add `let sectionsExpanded = false;` at module scope, reset by `teardown()`.
|
||||||
|
3. Insert `${renderSectionsEditor(sectionsDraft, sectionsExpanded)}` in the form HTML, just before `<div class="form-actions">`.
|
||||||
|
4. After `app.innerHTML = ...`, call `wireSectionsEditor(app, sectionsDraft, rerender)` where `rerender` replaces the disclosure subtree's innerHTML with a fresh `renderSectionsEditor(sectionsDraft, sectionsExpanded)`.
|
||||||
|
5. In save, replace `sections: existing?.sections ?? []` with `sections: sectionsDraft`.
|
||||||
|
|
||||||
|
`deepClone` helper: `JSON.parse(JSON.stringify(existing.sections))` is sufficient for the `Section[]` shape (no class instances, no Date objects, no undefined in positions that need to survive).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`types/__tests__/sections-edit.test.ts`:
|
||||||
|
- Open form (add mode), click disclosure toggle → data-expanded flips true.
|
||||||
|
- Click "+ add section" → one section appears; its field list is empty.
|
||||||
|
- Rename the section via mocked `window.prompt` → section header updates.
|
||||||
|
- Click "+ text" → a text field appears with label "new field" and empty value.
|
||||||
|
- Edit the label + value inputs → assertions on the in-memory sectionsDraft.
|
||||||
|
- Click save → `add_item` message's `item.sections` matches the draft structure.
|
||||||
|
- Round-trip on edit mode: pre-populate `existing` with sections, open form, confirm sections render expanded (since count > 0), add a field, save → outgoing sections has the new field appended.
|
||||||
|
|
||||||
|
### CSS additions
|
||||||
|
|
||||||
|
```css
|
||||||
|
.disclosure {
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.disclosure__toggle {
|
||||||
|
background: transparent; border: 0; color: #58a6ff;
|
||||||
|
cursor: pointer; font-size: 12px; padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.disclosure[data-expanded="false"] .disclosure__body { display: none; }
|
||||||
|
.section-editor__head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
|
margin-top: 10px; margin-bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__head .name { color: #c9d1d9; font-weight: 600; }
|
||||||
|
.section-editor__head .name.anon { color: #8b949e; font-style: italic; }
|
||||||
|
.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; }
|
||||||
|
.section-editor__head .actions button { background: transparent; border: 0; color: inherit; cursor: pointer; padding: 0; margin-left: 8px; }
|
||||||
|
.section-editor__field {
|
||||||
|
display: grid; grid-template-columns: 120px 1fr auto;
|
||||||
|
gap: 4px; margin-bottom: 4px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__field input {
|
||||||
|
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.section-editor__field .delete-field {
|
||||||
|
background: transparent; border: 0; color: #f85149; cursor: pointer;
|
||||||
|
font-size: 14px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.section-editor__add {
|
||||||
|
display: flex; gap: 6px; margin-top: 6px;
|
||||||
|
}
|
||||||
|
.section-editor__add button {
|
||||||
|
background: transparent; border: 1px solid #30363d; color: #8b949e;
|
||||||
|
padding: 2px 10px; border-radius: 3px; cursor: pointer; font-size: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; }
|
||||||
|
.disclosure__body .add-section {
|
||||||
|
margin-top: 12px; background: transparent;
|
||||||
|
border: 1px dashed #30363d; color: #8b949e;
|
||||||
|
padding: 6px 10px; border-radius: 4px; cursor: pointer;
|
||||||
|
width: 100%; font-size: 11px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slice 3 — Vault-settings SW plumbing
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
`shared/messages.ts` — add to `PopupMessage`:
|
||||||
|
```ts
|
||||||
|
| { type: 'get_vault_settings' }
|
||||||
|
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add both to `POPUP_ONLY_TYPES`. NOT in `SETUP_ALLOWED`.
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```ts
|
||||||
|
export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { settings: VaultSettings };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handlers (`router/popup-only.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case 'get_vault_settings': {
|
||||||
|
const handle = session.getCurrent();
|
||||||
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||||||
|
return { ok: true, data: { settings } };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_vault_settings': {
|
||||||
|
const handle = session.getCurrent();
|
||||||
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
await vault.encryptAndWriteSettings(
|
||||||
|
state.gitHost, handle, msg.settings,
|
||||||
|
'settings: update vault-level config',
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Router tests
|
||||||
|
|
||||||
|
`router/__tests__/router.test.ts` (+4 cases):
|
||||||
|
- `get_vault_settings` accepted from popup (mock `fetchAndDecryptSettings` → returns a `VaultSettings`); response shape matches `VaultSettingsResponse`.
|
||||||
|
- `get_vault_settings` rejected from content → `unauthorized_sender`.
|
||||||
|
- `update_vault_settings` accepted from popup; calls `encryptAndWriteSettings`.
|
||||||
|
- `update_vault_settings` rejected from setup tab (not in SETUP_ALLOWED).
|
||||||
|
|
||||||
|
### Popup init
|
||||||
|
|
||||||
|
`popup.ts#init`, after a successful unlock-is-active branch:
|
||||||
|
```ts
|
||||||
|
const vsResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (vsResp.ok) {
|
||||||
|
const vs = (vsResp.data as { settings: VaultSettings }).settings;
|
||||||
|
currentState.vaultSettings = vs;
|
||||||
|
currentState.generatorDefaults = vs.generator_defaults as GeneratorRequest;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetched once at popup open; refreshed after any `update_vault_settings` success. The "fetch on open" cost is one extra round-trip over α — acceptable given vault-settings drives multiple screens.
|
||||||
|
|
||||||
|
### `generate_passphrase` message (add if missing)
|
||||||
|
|
||||||
|
The α plan lists `generate_password` as a popup-only message. The generator popover (Slice 4) also needs `generate_passphrase` for BIP39 preview. Check `shared/messages.ts`; if absent, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
| { type: 'generate_passphrase'; request: GeneratorRequest }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `POPUP_ONLY_TYPES`. The SW handler mirrors `generate_password` but calls the `generate_passphrase` WASM function. One new case in `router/popup-only.ts`.
|
||||||
|
|
||||||
|
## Slice 4 — Generator inline popover
|
||||||
|
|
||||||
|
### `popup/components/generator-popover.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function openGeneratorPopover(opts: {
|
||||||
|
anchor: HTMLElement;
|
||||||
|
initial: GeneratorRequest;
|
||||||
|
onPicked: (value: string) => void;
|
||||||
|
}): void;
|
||||||
|
|
||||||
|
export function closeGeneratorPopover(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Module-scope state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let activePopover: {
|
||||||
|
host: HTMLElement;
|
||||||
|
onDismiss: () => void;
|
||||||
|
} | null = null;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout (Random kind)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ generate ────────────────── ✕ ┐
|
||||||
|
│ │
|
||||||
|
│ kind: [● Random] [○ BIP39] │
|
||||||
|
│ │
|
||||||
|
│ length: [════●═══════] 20 │
|
||||||
|
│ │
|
||||||
|
│ ☑ lowercase ☑ digits │
|
||||||
|
│ ☑ uppercase ☑ symbols │
|
||||||
|
│ │
|
||||||
|
│ symbols: [● safe] [○ extended] │
|
||||||
|
│ │
|
||||||
|
│ ─ preview ──────────────────── │
|
||||||
|
│ Kj7%pW@2xNq!8rMvT [↻] │
|
||||||
|
│ │
|
||||||
|
│ [reset to defaults] │
|
||||||
|
│ [save as default] │
|
||||||
|
│ [cancel] [use this value] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout (BIP39 kind)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ generate ────────────────── ✕ ┐
|
||||||
|
│ kind: [○ Random] [● BIP39] │
|
||||||
|
│ │
|
||||||
|
│ words: [═══●════════] 5 │
|
||||||
|
│ │
|
||||||
|
│ separator: [space] [-] [_] [.] [:]
|
||||||
|
│ │
|
||||||
|
│ capitalization: │
|
||||||
|
│ [● lower] [upper] [first] [title]
|
||||||
|
│ │
|
||||||
|
│ ─ preview ──────────────────── │
|
||||||
|
│ correct horse battery staple parapet
|
||||||
|
│ │
|
||||||
|
│ [reset to defaults] │
|
||||||
|
│ [save as default] │
|
||||||
|
│ [cancel] [use this value] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request construction
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function buildRequest(kind: 'random' | 'bip39', knobs: UiKnobs): GeneratorRequest {
|
||||||
|
if (kind === 'random') {
|
||||||
|
return {
|
||||||
|
kind: 'random',
|
||||||
|
length: knobs.length,
|
||||||
|
classes: {
|
||||||
|
lower: knobs.lower, upper: knobs.upper,
|
||||||
|
digits: knobs.digits, symbols: knobs.symbols,
|
||||||
|
},
|
||||||
|
symbol_charset:
|
||||||
|
knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } :
|
||||||
|
knobs.symbolCharset === 'extended' ? { kind: 'extended' } :
|
||||||
|
{ kind: 'custom', value: knobs.customSymbols ?? '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'bip39',
|
||||||
|
word_count: knobs.wordCount,
|
||||||
|
separator: knobs.separator,
|
||||||
|
capitalization: knobs.capitalization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preview refresh
|
||||||
|
|
||||||
|
On any knob change, debounced 150ms:
|
||||||
|
```ts
|
||||||
|
async function refreshPreview(): Promise<void> {
|
||||||
|
const request = buildRequest(uiKind, uiKnobs);
|
||||||
|
const msg = uiKind === 'random'
|
||||||
|
? { type: 'generate_password' as const, request }
|
||||||
|
: { type: 'generate_passphrase' as const, request };
|
||||||
|
const resp = await sendMessage(msg);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { password?: string; passphrase?: string };
|
||||||
|
const previewEl = activePopover?.host.querySelector('.gen-preview__value');
|
||||||
|
if (previewEl) previewEl.textContent = data.password ?? data.passphrase ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: α added `generate_password` but `generate_passphrase` may need to be added (check α's `messages.ts`). If not present, add it alongside generate_password in slice 4's scope (router handler already accepts a `request_json` → WASM `generate_passphrase`).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
"use this value" button disabled when:
|
||||||
|
- Random kind and no char-class checked (`!lower && !upper && !digits && !symbols`).
|
||||||
|
- BIP39 kind never disabled (always valid — word count ≥ 3).
|
||||||
|
|
||||||
|
Visual cue: when disabled, button is dimmed + a `<p class="gen-validation">pick at least one character class</p>` renders below.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
- **use this value**: `onPicked(currentPreview); close();`. Host field's setter wraps this (e.g., `pw.value = value; pw.type = 'text';` for the Login form).
|
||||||
|
- **save as default**: fetch the full `vaultSettings` via `sendMessage({ type: 'get_vault_settings' })`; write `{ ...vaultSettings, generator_defaults: currentRequest }` via `update_vault_settings`. On success: update `state.vaultSettings` + `state.generatorDefaults`; flash "saved" on the button for 1.5s; do NOT close.
|
||||||
|
- **reset to defaults**: reset UI knobs to `state.generatorDefaults ?? DEFAULT_PASSWORD_REQUEST`; refresh preview.
|
||||||
|
- **cancel / Escape / outside-click**: close without callback.
|
||||||
|
|
||||||
|
### Teardown wiring
|
||||||
|
|
||||||
|
Every type module's existing `teardown()` gains:
|
||||||
|
```ts
|
||||||
|
closeGeneratorPopover();
|
||||||
|
```
|
||||||
|
So navigation or re-rendering always cleans up the popover.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`__tests__/generator-popover.test.ts` (mocks `sendMessage`):
|
||||||
|
- Open with default initial → renders Random kind, shows `length=20`, all 4 classes checked, safe_only.
|
||||||
|
- BIP39 toggle → switches knobs to word-count / separator / capitalization; `sendMessage` called with `generate_passphrase`.
|
||||||
|
- Length slider change → debounced `generate_password` call with updated `length`.
|
||||||
|
- "use this value" → `onPicked` called with current preview string; popover closes.
|
||||||
|
- "save as default" → `update_vault_settings` called with the current request merged into vaultSettings.
|
||||||
|
- Uncheck all 4 classes in Random → "use this value" button disabled.
|
||||||
|
- Escape key → popover closes without invoking onPicked.
|
||||||
|
|
||||||
|
## Slice 5 — Settings view + revoke + default wiring
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
`popup.ts`:
|
||||||
|
- Add `'settings-vault'` to the `View` union.
|
||||||
|
- Add the render-switch case pointing at `renderVaultSettings`.
|
||||||
|
- Toolbar ⚙ button on `item-list.ts` becomes a tiny picker (render inline, same pattern as the "+ New" picker):
|
||||||
|
|
||||||
|
```
|
||||||
|
⚙
|
||||||
|
├ device settings → navigate('settings')
|
||||||
|
└ vault settings → navigate('settings-vault')
|
||||||
|
```
|
||||||
|
|
||||||
|
### `popup/components/settings-vault.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function renderVaultSettings(app: HTMLElement): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
Module-scope state:
|
||||||
|
- `pendingSettings: VaultSettings | null` — draft, initialized from `state.vaultSettings`, mutated by the screen.
|
||||||
|
- `teardown()` exported; removes any active key handler.
|
||||||
|
|
||||||
|
### Render body
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="pad">
|
||||||
|
<div class="settings-header">
|
||||||
|
<button class="btn" id="back-btn">← back</button>
|
||||||
|
<h3>vault settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">retention</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">trash</span>
|
||||||
|
<select id="trash-retention">...</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">field history</span>
|
||||||
|
<select id="history-retention">...</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">generator</div>
|
||||||
|
<p class="gen-preview-line">{humanSummary(pending.generator_defaults)}</p>
|
||||||
|
<button class="btn" id="configure-gen">configure ▾</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">autofill origins</div>
|
||||||
|
{if empty: <p class="muted">No origins acknowledged yet.</p>}
|
||||||
|
{else: sorted ack rows with revoke buttons}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button class="btn" id="discard-btn">discard</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retention dropdown semantics
|
||||||
|
|
||||||
|
`retentionSelectOptions(kind: 'trash' | 'history')`:
|
||||||
|
- Trash: `Forever`, `7 days`, `30 days`, `60 days`, `90 days`, `180 days`, `365 days`, `custom…`.
|
||||||
|
- History: `Forever`, `Last 3`, `Last 5`, `Last 10`, `30 days`, `90 days`, `365 days`, `custom…`.
|
||||||
|
|
||||||
|
`retentionToSelectValue(r)` maps a `TrashRetention` / `HistoryRetention` union to one of those option labels (falling back to `custom…` if it's an N that doesn't match a preset).
|
||||||
|
|
||||||
|
`selectValueToRetention(kind, label)` goes the other way. For `custom…`, `prompt()` the user for a number + unit.
|
||||||
|
|
||||||
|
### Generator-default preview
|
||||||
|
|
||||||
|
`humanSummary(req: GeneratorRequest): string`:
|
||||||
|
- Random: `"Random, {length} chars, {classes joined with +}, {symbolCharset label}"`.
|
||||||
|
- BIP39: `"BIP39, {word_count} words, {separator label}-separated, {capitalization}"`.
|
||||||
|
|
||||||
|
Clicking "configure ▾" opens the generator popover (`openGeneratorPopover`) with `onPicked: () => {}` (no-op — the user's intent here is "save as default", not "insert into a field"). On popover close (after save-as-default or cancel), refresh `state.vaultSettings` via a `get_vault_settings` round-trip and re-render the settings screen. (The popover's "save as default" already calls `update_vault_settings` itself.)
|
||||||
|
|
||||||
|
### Origin-ack list
|
||||||
|
|
||||||
|
Sorted by `Object.entries(acks).sort(([, a], [, b]) => b - a)` (most recent first).
|
||||||
|
|
||||||
|
Each row:
|
||||||
|
```html
|
||||||
|
<div class="ack-row">
|
||||||
|
<span class="ack-row__host">github.com</span>
|
||||||
|
<span class="ack-row__meta">acked 3d ago</span>
|
||||||
|
<button class="ack-row__revoke" data-host="github.com">revoke</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Revoke handler: `delete pending.autofill_origin_acks[host]; rerender(); markDirty();`.
|
||||||
|
|
||||||
|
### Save / discard
|
||||||
|
|
||||||
|
`markDirty()` enables the save button. `save` sends `update_vault_settings` with `pending`; on success, updates `state.vaultSettings` + `state.generatorDefaults` and navigates back to the list. `discard` just navigates back.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`__tests__/settings-vault.test.ts`:
|
||||||
|
- Render with seeded `state.vaultSettings` — correct retention labels shown.
|
||||||
|
- Change trash-retention select → `pending` updated; save button enabled.
|
||||||
|
- Click revoke on an ack → `pending.autofill_origin_acks` loses that key; save button enabled.
|
||||||
|
- Save → `update_vault_settings` called with `pending`; navigates back.
|
||||||
|
- Discard → no message sent; navigates back.
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
Additions in `popup/styles.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.settings-header h3 { margin: 0; font-size: 14px; }
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 14px; padding-top: 10px;
|
||||||
|
border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
.settings-section__title {
|
||||||
|
color: #8b949e; font-size: 10px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: grid; grid-template-columns: 110px 1fr;
|
||||||
|
gap: 6px 10px; align-items: center;
|
||||||
|
margin: 4px 0; font-size: 12px;
|
||||||
|
}
|
||||||
|
.settings-row__label { color: #8b949e; }
|
||||||
|
.settings-row select {
|
||||||
|
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||||||
|
padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.gen-preview-line {
|
||||||
|
margin: 0 0 6px; font-size: 11px; color: #c9d1d9;
|
||||||
|
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
.ack-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 4px 0; font-size: 11px;
|
||||||
|
border-bottom: 1px solid #161b22;
|
||||||
|
}
|
||||||
|
.ack-row__host { color: #c9d1d9; font-family: monospace; }
|
||||||
|
.ack-row__meta { color: #6e7681; font-size: 10px; }
|
||||||
|
.ack-row__revoke {
|
||||||
|
background: transparent; border: 0; color: #f85149;
|
||||||
|
cursor: pointer; font-size: 10px;
|
||||||
|
}
|
||||||
|
.settings-footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 6px;
|
||||||
|
margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
No Rust changes. `cargo test --workspace` stays green (155 tests from β₁).
|
||||||
|
|
||||||
|
### Vitest
|
||||||
|
Existing 84 tests stay green. New tests:
|
||||||
|
- `types/__tests__/sections-render.test.ts` — ~5 tests.
|
||||||
|
- `types/__tests__/sections-edit.test.ts` (or per-type variants as appropriate) — ~5 tests.
|
||||||
|
- `__tests__/generator-popover.test.ts` — ~7 tests.
|
||||||
|
- `router/__tests__/router.test.ts` (extensions) — ~4 tests.
|
||||||
|
- `__tests__/settings-vault.test.ts` — ~5 tests.
|
||||||
|
|
||||||
|
Target post-β₂: ~110 tests.
|
||||||
|
|
||||||
|
### Manual matrix
|
||||||
|
|
||||||
|
1. Add a Login item; in the form's disclosure, add a section named "recovery codes" with two password fields; save; open detail → sections appear below typed rows; reveal works on each concealed row; copy works on text rows.
|
||||||
|
2. Edit the same item; remove one field; add a text field; save; detail reflects all three changes.
|
||||||
|
3. Click ⚙ → vault settings; change trash retention to `7 days`; save; reload → still `7 days`.
|
||||||
|
4. In vault settings, click "configure ▾" on the generator preview; change kind to BIP39; save as default; close popover; preview shows BIP39 summary. Reload → still BIP39.
|
||||||
|
5. Back on Login form, click "gen" → popover opens with BIP39 defaults (inherited from settings).
|
||||||
|
6. "use this value" on the popover fills the password field with a BIP39 phrase.
|
||||||
|
7. Revoke an origin ack; save; attempt autofill on that site → requires-ack flow re-triggers (per α's content-callable handler).
|
||||||
|
8. Kind toggle mid-popover switches Random ↔ BIP39; preview refreshes; request shape correct.
|
||||||
|
|
||||||
|
### Acceptance
|
||||||
|
|
||||||
|
- `cargo test --workspace` green.
|
||||||
|
- `bun run test` green (~110 tests).
|
||||||
|
- `bun run build:all` green for Chrome + Firefox.
|
||||||
|
- `git grep -n '@ts-nocheck' extension/src/` → 0.
|
||||||
|
- `git grep -n 'coming-soon\|Coming in' extension/src/popup/components/ | grep -v document` → 0.
|
||||||
|
- Manual matrix 8 steps pass on both browsers.
|
||||||
|
|
||||||
|
## Open questions deferred to plan
|
||||||
|
|
||||||
|
- `generate_passphrase` message type: α shipped `generate_password`; if the message union lacks `generate_passphrase`, add it in Slice 4 alongside the vault-settings messages. The SW router just needs an additional case mirroring `generate_password`.
|
||||||
|
- Custom-field label blanks: what happens when a field has an empty `label`? Options: (a) reject at save time; (b) allow and render as "(unnamed)". Plan ships (b) — no UX friction; render the value row with the row's label span empty.
|
||||||
|
- Retention `custom…`: is the `prompt()` acceptable UX, or should it be an inline number + unit input? Plan ships `prompt()` (matches existing rename-section UX); can polish in a later pass.
|
||||||
|
- Deep-equal check for save-button enable: `JSON.stringify(a) === JSON.stringify(b)` is cheap and sufficient for the `VaultSettings` shape (no Map/Set/Date keys). Avoids a util dependency.
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# Plan 1C-γ₁: Attachments + Document type — design
|
||||||
|
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Scope:** Wire the existing Rust attachment-encryption surface into the extension, add the Git host's missing `putBlob` operation (with Git Data API fallback for large blobs), introduce the Document item type that's been a "coming soon" stub since β₁, and surface attachments in both add-flow and view-flow inside the popup.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The Rust core has shipped attachment encryption (`attachment_encrypt` / `attachment_decrypt`, exposed in WASM), the manifest already reserves an `attachment_summaries: Vec<AttachmentSummary>` field on each entry, and every typed item form already round-trips an `attachments: Vec<AttachmentRef>` array (currently always empty from the popup's side). What's missing is the extension plumbing: a UI to add/view attachments, a Document item-type form, a way to encrypt+upload the bytes through the service worker, and the missing `GitHost.putBlob` op (with the >900 KB Git Data API fallback that Contents API can't handle because of base64 inflation).
|
||||||
|
|
||||||
|
γ₂ (later) will surface the views over already-supported core capabilities: trash, field history, device management, and the attachment-caps UI.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Trash view, field history view, device management UI, attachment-caps configuration UI — all γ₂.
|
||||||
|
- Drag-drop file affordance — file picker only in v1; drag-drop is polish that can land later if usage warrants.
|
||||||
|
- Multi-file upload at once — single file per pick; reduces edge cases (caps overshoot, partial-upload state) for γ₁.
|
||||||
|
- Attachment count on item-list rows — γ₁ shows just a `📎` icon when an item has any attachments; the count is deferred (most users won't care about exact count, and it's noise on narrow rows).
|
||||||
|
- Inline image preview panels in the disclosure body — γ₁ uses thumb-icons (16×16) in the row; click → browser download. The big inline-preview pane was option C earlier and was rejected as overkill for the popup's vertical budget.
|
||||||
|
- Document type's signature-block image preview at large size — the detail view's gold "signature block" shows a 36×60 thumb plus filename/meta. Full-size in-popup viewing is not provided; user downloads to view full-resolution.
|
||||||
|
|
||||||
|
## Visual identity
|
||||||
|
|
||||||
|
### Attachments — compact disclosure (locked: pattern A)
|
||||||
|
|
||||||
|
Every typed-item form (Login, SecureNote, Identity, Card, Key, TOTP, **Document**) gets an `attachments` disclosure rendered AFTER the type-specific fields and AFTER the existing `custom fields` disclosure. The disclosure header reads:
|
||||||
|
|
||||||
|
- Empty: `▸ attachments`
|
||||||
|
- Populated: `▾ attachments (N)` (with N being the count from `core.attachments.length`)
|
||||||
|
|
||||||
|
Disclosure body in **edit mode**:
|
||||||
|
|
||||||
|
- One row per existing attachment: `[icon-or-thumb] filename 12 KB ×`
|
||||||
|
- A "+ attach file" button at the bottom, full-width, dashed border (`#30363d`), color `#8b949e` → `#c9d1d9` on hover. Clicking it triggers a hidden `<input type="file">` with no `multiple` attr.
|
||||||
|
- The `×` removes the attachment from the editing item draft (does NOT delete the underlying blob from the git host until the item is saved — see "lifecycle" below).
|
||||||
|
|
||||||
|
Disclosure body in **view mode**:
|
||||||
|
|
||||||
|
- One row per attachment, same layout as edit mode, but the action column is `↓` (download) instead of `×`.
|
||||||
|
- Click the row OR click the `↓` → triggers a browser download of the decrypted attachment via `chrome.downloads.download` (or anchor-tag fallback for Firefox). Browser handles preview for known mime types when the user opens the downloaded file.
|
||||||
|
- No "+ attach file" button (only available in edit mode).
|
||||||
|
|
||||||
|
### Image attachments — thumb-icon column (locked: pattern B)
|
||||||
|
|
||||||
|
For attachment rows where `mime_type` starts with `image/`, the leading icon column renders a 16×16 thumbnail of the image instead of the generic `📄` glyph. The thumbnail is generated lazily:
|
||||||
|
|
||||||
|
- On disclosure open, image-mime rows fetch their decrypted blob via SW message, create a `URL.createObjectURL(blob)` object URL, and use it as the `<img>` src in the icon column.
|
||||||
|
- On disclosure close (or item navigation away, or popup close), all created object URLs are `URL.revokeObjectURL`'d in a teardown function. This keeps memory bounded.
|
||||||
|
- For non-image attachments: render the generic `📄` glyph; no decryption work happens until the user clicks download.
|
||||||
|
|
||||||
|
### Document type (locked: pattern C)
|
||||||
|
|
||||||
|
The Document type's identifying field is its `primary_attachment` — a single, REQUIRED file. Form composition:
|
||||||
|
|
||||||
|
- **title** (required, lowercase label + gold `*` per project polish)
|
||||||
|
- **primary attachment** (required, gold `*`) — compact-row picker. Empty state: dashed-border `+ attach primary file` button. Filled state: a single row with `[thumb] filename 240 KB ↑ change`. Clicking `↑ change` re-triggers the file picker.
|
||||||
|
- **notes** (optional, textarea)
|
||||||
|
- **tags** (optional, comma-separated input — single text input with chip-style display below if implementer wants polish)
|
||||||
|
- **expires** (optional, MM/YYYY two-input row using the existing pattern from Card's expiry)
|
||||||
|
- **attachments disclosure** (optional supplementary attachments — same compact-disclosure pattern as other types)
|
||||||
|
|
||||||
|
Detail view promotes the primary attachment to a **signature block** (gold left-border `border-left: 3px solid #aa812a`, `#161b22` background, padding 10 px). The signature block contains:
|
||||||
|
|
||||||
|
- Left: 48×60 thumbnail (`linear-gradient(135deg, #b88a30, #7c5719)` for non-image; actual decrypted thumb for image-mime)
|
||||||
|
- Right: filename (font 11 px, `#f1cf6e`, weight 600), meta line below in `#8b949e` showing `size · created` (e.g. `240 KB · 2026-04-12`), and an action line `↓ download · 🔍 preview` where `🔍 preview` only appears for image-mime attachments and triggers an inline expanded preview within the signature block (toggle).
|
||||||
|
|
||||||
|
Below the signature block, the standard typed-rows render (notes, tags, expires) and finally the supplementary `attachments` disclosure if any exist.
|
||||||
|
|
||||||
|
### Item-list attachment indicator (locked: 2c)
|
||||||
|
|
||||||
|
Item-list rows currently render `[type-icon] [title] [favorite-star]`. After γ₁, items with at least one attachment also show a small `📎` glyph just before the favorite-star slot. No count is rendered. The row template change is one optional span; styling reuses the existing muted-text class.
|
||||||
|
|
||||||
|
For Document items, the `📎` is implicit (every Document has at least the primary attachment), but we still render it for consistency.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Upload pipeline
|
||||||
|
|
||||||
|
End-to-end flow when the user clicks "+ attach file":
|
||||||
|
|
||||||
|
1. Hidden `<input type="file">` opens browser file picker.
|
||||||
|
2. On change, popup reads file via `FileReader.readAsArrayBuffer` (or just `file.arrayBuffer()`).
|
||||||
|
3. Popup checks the bytes against `VaultSettings.attachment_caps.per_attachment_max_bytes` — if exceeded, show toast `"file too large (X MB / cap is Y MB)"` and abort. (Caps default to undefined / no limit if `attachment_caps` is empty; γ₂ adds the UI to set them.)
|
||||||
|
4. Popup sends `{type: 'upload_attachment', itemId: <id>, filename, mimeType, bytes: <ArrayBuffer>}` to SW. The bytes go via `chrome.runtime.sendMessage` structured-clone — Chrome's per-message limit is generously above any reasonable attachment size we're targeting.
|
||||||
|
5. SW receives in `popup-only.ts`:
|
||||||
|
- Verifies sender (popup-only, per existing router pattern)
|
||||||
|
- Calls WASM `attachment_encrypt(sessionHandle, bytes)` → returns `{id, bytes: encryptedBytes}` where `id = sha256(plaintext)`
|
||||||
|
- Constructs the storage path: `attachments/<id>.bin` (within the configured vault root path on the git host)
|
||||||
|
- Calls `gitHost.putBlob(path, encryptedBytes, message)` — the new operation on `GitHost` interface
|
||||||
|
- Updates the item's `attachments` array to include the new `AttachmentRef { id, filename, mime_type, size: plaintextLen, created: now() }`
|
||||||
|
- Updates the manifest's per-entry `attachment_summaries` array
|
||||||
|
- Persists both updated item.json and manifest.json via `gitHost.writeFile` (Contents API; small files)
|
||||||
|
- Returns `{ok: true, attachment: AttachmentRef}` to the popup
|
||||||
|
6. Popup updates its in-memory item draft (or re-fetches the item from SW), re-renders the attachments disclosure with the new row, hides loading state.
|
||||||
|
|
||||||
|
If any step fails, SW returns `{ok: false, error: 'upload_failed', detail: '...'}` and popup shows toast `"upload failed: <reason>"` without modifying the draft.
|
||||||
|
|
||||||
|
### `GitHost.putBlob` — interface extension
|
||||||
|
|
||||||
|
The `GitHost` interface (`extension/src/service-worker/git-host.ts`) currently has 4 ops: `readFile`, `writeFile`, `deleteFile`, `listDir`. γ₁ adds:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/// Write an opaque binary blob to the repo. Unlike writeFile, this is
|
||||||
|
/// optimized for large attachments — implementations choose between
|
||||||
|
/// Contents API (small) and Git Data API (large) based on byte length.
|
||||||
|
/// Returns the path that was written (same as input, for chaining).
|
||||||
|
putBlob(path: string, content: Uint8Array, message: string): Promise<string>;
|
||||||
|
|
||||||
|
/// Read an opaque binary blob from the repo. Same semantics as readFile
|
||||||
|
/// for small files; for large files the implementation may use the
|
||||||
|
/// Git Data API to fetch the blob by its sha rather than the contents
|
||||||
|
/// endpoint.
|
||||||
|
getBlob(path: string): Promise<Uint8Array>;
|
||||||
|
|
||||||
|
/// Delete a blob from the repo. For now identical to deleteFile, but
|
||||||
|
/// kept distinct so future fallback paths (Git Data API) have a hook.
|
||||||
|
deleteBlob(path: string, message: string): Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`getBlob` mirrors `readFile` but is named distinctly so we can later add streaming/chunked reads if needed. For γ₁ it's just a thin wrapper over `readFile` with no fallback (Contents API GET works for files up to 100 MB on GitHub; we read the encrypted bytes back as base64-decoded Uint8Array).
|
||||||
|
|
||||||
|
`deleteBlob` is also a thin wrapper for now; kept distinct for symmetry with putBlob.
|
||||||
|
|
||||||
|
### `putBlob` fallback strategy
|
||||||
|
|
||||||
|
In `GitHubHost.putBlob` and `GiteaHost.putBlob`:
|
||||||
|
|
||||||
|
```
|
||||||
|
const THRESHOLD_BYTES = 900 * 1024; // 900 KB pre-base64
|
||||||
|
|
||||||
|
if (content.length <= THRESHOLD_BYTES) {
|
||||||
|
// Use existing writeFile path (Contents API PUT with base64-encoded content)
|
||||||
|
await this.writeFile(path, content, message);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git Data API fallback for large blobs:
|
||||||
|
// 1. POST /repos/{owner}/{repo}/git/blobs → returns blob SHA
|
||||||
|
// 2. GET /repos/{owner}/{repo}/branches/{branch} → get current commit SHA + tree SHA
|
||||||
|
// 3. POST /repos/{owner}/{repo}/git/trees → create new tree with blob added at path, base_tree = current tree
|
||||||
|
// 4. POST /repos/{owner}/{repo}/git/commits → create commit with new tree, parent = current commit
|
||||||
|
// 5. PATCH /repos/{owner}/{repo}/git/refs/heads/{branch} → fast-forward branch to new commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Both GitHub and Gitea expose these endpoints with the same shape (Gitea modeled itself after GitHub). One concrete divergence: the Gitea v1 API uses `/api/v1/repos/...` prefix; GitHub uses `/repos/...`. The existing `gitea.ts` and `github.ts` already encapsulate this divergence in their constructors. The fallback path adds 4 more endpoint calls per impl; we add them as private helper methods (`createGitBlob`, `getRefSha`, `createGitTree`, `createGitCommit`, `updateRef`) and orchestrate them in `putBlob`.
|
||||||
|
|
||||||
|
The threshold `900 * 1024` is a constant exported from `git-host.ts` so both implementations agree. 900 KB pre-base64 → ~1.2 MB after base64 → safely under GitHub's 1 MB Contents API soft-limit and well under Gitea's tolerance.
|
||||||
|
|
||||||
|
### Manifest update flow
|
||||||
|
|
||||||
|
After every attach/detach, both the item file (`items/<id>.json`) and the manifest file (`manifest.json`) need to be updated atomically-as-possible:
|
||||||
|
|
||||||
|
- Item file: re-encrypt the entire item with new `attachments` array
|
||||||
|
- Manifest: re-encrypt the entire manifest with the entry's `attachment_summaries` field updated
|
||||||
|
|
||||||
|
Two separate `writeFile` calls (the manifest is small, item file is small — both well under threshold). We accept the brief window where item is updated but manifest isn't yet: in the worst case, the popup sees the item with the new attachment but the manifest list view doesn't show the `📎` indicator until the next sync. This is acceptable — the user is the only one writing, and the popup will eagerly re-fetch the manifest after the upload completes.
|
||||||
|
|
||||||
|
The blob itself (`attachments/<id>.bin`) is written FIRST, so even if the item/manifest writes fail, the blob exists in the repo (and a subsequent retry can reattach it). Orphaned blobs (referenced by no item) are tolerable — γ₂'s attachment-caps UI can include a "purge orphans" action later.
|
||||||
|
|
||||||
|
### Attachment lifecycle
|
||||||
|
|
||||||
|
**Add (during item edit):**
|
||||||
|
1. User clicks "+ attach file" → file picker → chooses file
|
||||||
|
2. Popup sends `upload_attachment` to SW with the bytes
|
||||||
|
3. SW encrypts + putBlobs the encrypted bytes immediately (synchronous from the user's perspective; toast "uploading..." → "✓ added" or "✕ failed")
|
||||||
|
4. SW returns the `AttachmentRef`; popup adds it to its in-memory item draft
|
||||||
|
5. The item itself isn't saved until the user clicks "save" on the form. So between step 4 and the user clicking save, the blob exists in the repo but no item references it. If the user clicks "cancel" instead of save, the orphaned blob stays (will be garbage-collected by γ₂'s purge-orphans action).
|
||||||
|
|
||||||
|
**Remove (during item edit):**
|
||||||
|
1. User clicks `×` on an existing attachment row in the disclosure
|
||||||
|
2. Popup removes the row from the in-memory draft (visual immediate)
|
||||||
|
3. The blob is NOT deleted yet; on form save, the SW compares the saved item's attachments vs. the original and `deleteBlob`s any that were removed
|
||||||
|
4. If user clicks "cancel" instead of save, the original item (and its blobs) are unchanged
|
||||||
|
|
||||||
|
**Save with new attachments:**
|
||||||
|
1. User clicks "save"
|
||||||
|
2. Popup sends the updated item to SW (existing flow); SW writes item.json + manifest.json
|
||||||
|
3. Any deferred deletes (from removes during edit) are processed: SW iterates the original-attachments-minus-current-attachments set and `deleteBlob`s each
|
||||||
|
4. Failures are best-effort: a failed delete doesn't block the save; orphaned blobs are tolerable
|
||||||
|
|
||||||
|
**Download (in detail view):**
|
||||||
|
1. User clicks attachment row
|
||||||
|
2. Popup sends `{type: 'download_attachment', itemId, attachmentId}` to SW
|
||||||
|
3. SW reads the blob via `getBlob`, decrypts via `attachment_decrypt(sessionHandle, encryptedBytes)`
|
||||||
|
4. SW returns the decrypted bytes (ArrayBuffer)
|
||||||
|
5. Popup creates a Blob with the original `mime_type`, generates an object URL via `URL.createObjectURL`, triggers download via `chrome.downloads.download({url, filename})`, then revokes the URL after a brief delay
|
||||||
|
|
||||||
|
**Image thumb rendering (in detail view):**
|
||||||
|
- Same as download except step 5 sets the object URL as the `<img>` src instead of triggering a download
|
||||||
|
- Object URLs are tracked in a per-disclosure registry and revoked on disclosure close / navigation
|
||||||
|
|
||||||
|
### Caps enforcement (γ₁ enforces, γ₂ configures)
|
||||||
|
|
||||||
|
Caps live in `VaultSettings.attachment_caps` (β₂ shipped the schema; the UI is γ₂). γ₁ reads the four caps and enforces:
|
||||||
|
|
||||||
|
- `per_attachment_max_bytes`: rejected at popup before sending to SW (cheap; fails fast)
|
||||||
|
- `per_item_max_count`: count of attachments on the item (not bytes); rejected at popup
|
||||||
|
- `per_vault_soft_cap_bytes`: sum of plaintext sizes across all items (computed from manifest summaries); shows warning toast but allows upload
|
||||||
|
- `per_vault_hard_cap_bytes`: same sum; hard reject at popup
|
||||||
|
|
||||||
|
If any cap is `undefined`, no limit is enforced for that level. γ₂ will surface the configuration UI; in γ₁ users without explicit caps get unlimited attachments (modulo the implementation's practical limits — large blobs work via Git Data API, but uploading a 50 MB file will be slow).
|
||||||
|
|
||||||
|
## Files affected
|
||||||
|
|
||||||
|
### Rust core (likely no changes)
|
||||||
|
|
||||||
|
The Rust core already has everything we need:
|
||||||
|
- `attachment.rs`: `AttachmentRef`, `AttachmentSummary`, `EncryptedAttachment`, `encrypt_attachment`, `decrypt_attachment`
|
||||||
|
- `item_types/document.rs`: `DocumentCore { filename, mime_type, primary_attachment }`
|
||||||
|
- `manifest.rs`: per-entry `attachment_summaries: Vec<AttachmentSummary>`
|
||||||
|
- WASM exports: `attachment_encrypt`, `attachment_decrypt`
|
||||||
|
|
||||||
|
If a gap surfaces during implementation (e.g. missing helper), the plan will note it and add a small Rust task. But the design assumes the Rust surface is complete.
|
||||||
|
|
||||||
|
### Service worker
|
||||||
|
|
||||||
|
- `extension/src/service-worker/git-host.ts` — extend `GitHost` interface with `putBlob`, `getBlob`, `deleteBlob`. Export `BLOB_THRESHOLD_BYTES = 900 * 1024`.
|
||||||
|
- `extension/src/service-worker/github.ts` — implement `putBlob` (with Git Data API fallback), `getBlob`, `deleteBlob`. Add 5 private helper methods for the Git Data API endpoints.
|
||||||
|
- `extension/src/service-worker/gitea.ts` — same as github.ts. Endpoints have `/api/v1/` prefix; payload shapes are identical.
|
||||||
|
- `extension/src/service-worker/router/popup-only.ts` — add 2 message handlers: `upload_attachment` and `download_attachment`. Both wired to existing `popup_only` sender check.
|
||||||
|
- `extension/src/service-worker/vault.ts` — add helpers: `addAttachmentToItem(itemId, attachmentRef)`, `removeAttachmentsFromItem(itemId, idsToRemove)`. Both update item.json + manifest.json.
|
||||||
|
|
||||||
|
### Popup
|
||||||
|
|
||||||
|
- `extension/src/popup/components/attachments-disclosure.ts` — NEW. Renders the compact disclosure (header + rows + "+ attach file" button). Accepts the item draft, the form mode (`'add' | 'edit' | 'view'`), and an `onChange(attachments)` callback for edit mode. Manages object-URL lifecycle for image thumbs in view mode.
|
||||||
|
- `extension/src/popup/components/types/document.ts` — NEW. Same shape as other type components (renderForm, renderDetail, save handler). Includes the primary-attachment picker and the signature-block detail rendering.
|
||||||
|
- `extension/src/popup/components/item-form.ts` — wire up Document case in the dispatcher (currently routes to `renderComingSoon`).
|
||||||
|
- `extension/src/popup/components/item-list.ts` — add the `📎` indicator span in the row template when `entry.attachment_summaries.length > 0`.
|
||||||
|
- `extension/src/popup/components/types/{login,secure-note,identity,card,key,totp}.ts` — add `attachmentsDisclosure(...)` call after the custom-fields disclosure in each renderForm. ~3 lines per file.
|
||||||
|
- `extension/src/popup/styles.css` — add rules for `.attachment-row`, `.attachment-row__thumb`, `.attachment-row__name`, `.attachment-row__meta`, `.attachment-row__action`; `.attachment-add-btn`; `.document-signature-block` (signature-block treatment).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `extension/src/service-worker/__tests__/git-host.test.ts` — NEW. Test putBlob threshold logic (with mocked fetch): small payload uses Contents API; payload >900KB uses Git Data API sequence (5-call mock). Verify failure paths bubble up.
|
||||||
|
- `extension/src/popup/components/__tests__/attachments-disclosure.test.ts` — NEW. Test render in each mode, +attach triggers file picker, × removes from draft, ↓ triggers download message, image-mime rows lazy-load thumbs, object URLs revoked on close.
|
||||||
|
- `extension/src/popup/components/types/__tests__/document.save.test.ts` — NEW. Test Document form save: missing primary_attachment shows validation error; valid save sends correct wire format.
|
||||||
|
- `extension/src/service-worker/router/__tests__/router.test.ts` — extend with 2 cases: `upload_attachment` accepted from popup, rejected from content; same for `download_attachment`.
|
||||||
|
|
||||||
|
Estimated test count growth: ~15 new tests (was 128 after gen-UX, target ~143).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] Clicking "+ attach file" on any item form opens browser file picker.
|
||||||
|
- [ ] Picking a file uploads it (encrypted) to the configured git host within ~1-3 seconds for typical files (<1 MB) or up to ~10s for large files (>5 MB).
|
||||||
|
- [ ] The new attachment appears in the disclosure immediately after upload.
|
||||||
|
- [ ] Saving the item persists the updated `attachments` array; reopening shows the attachment.
|
||||||
|
- [ ] In view mode, clicking ↓ downloads the decrypted file to the user's downloads folder.
|
||||||
|
- [ ] Image-mime attachments show a 16×16 thumb in the icon column (lazily decrypted on disclosure open).
|
||||||
|
- [ ] Removing an attachment in edit mode + saving deletes the underlying blob from the git host.
|
||||||
|
- [ ] Item-list rows show `📎` for items with at least one attachment.
|
||||||
|
- [ ] Document item type's "+ New" entry is no longer "coming soon" — opens the Document form.
|
||||||
|
- [ ] Document form rejects save when `primary_attachment` is empty (gold required-field treatment).
|
||||||
|
- [ ] Document detail view renders the gold signature block with thumb + filename + meta + actions.
|
||||||
|
- [ ] Documents with image primary_attachment offer a `🔍 preview` toggle; clicking expands an inline preview pane within the signature block.
|
||||||
|
- [ ] putBlob with content >900 KB uses the Git Data API fallback (verified via test with mocked fetch + via manual upload of a >1 MB file to a real test repo).
|
||||||
|
- [ ] putBlob with content ≤900 KB uses the existing Contents API path (no Git Data API calls).
|
||||||
|
- [ ] `bun run test` passes (existing 128 + ~15 new = ~143 tests).
|
||||||
|
- [ ] `bun run build:all` clean for both Chrome and Firefox.
|
||||||
|
- [ ] `cargo test --workspace` passes (155).
|
||||||
|
- [ ] `bunx tsc --noEmit` clean.
|
||||||
|
- [ ] Manual smoke: walk through Login form add+remove attachment, Document form create+view, attachment > 1 MB triggers Git Data API path, item-list shows 📎 indicator.
|
||||||
|
|
||||||
|
## Out of scope (deferred to γ₂ or later)
|
||||||
|
|
||||||
|
- Trash view + restore/purge actions (γ₂)
|
||||||
|
- Field history view per item (γ₂)
|
||||||
|
- Device add/list/revoke UI (γ₂)
|
||||||
|
- Attachment caps configuration UI (γ₂; γ₁ reads them but doesn't edit them)
|
||||||
|
- Drag-drop file affordance
|
||||||
|
- Multi-file picker
|
||||||
|
- Attachment count badge on item-list rows
|
||||||
|
- Inline image preview pane in the standard attachments disclosure (only Document's primary attachment gets the preview-on-toggle)
|
||||||
|
- Orphan-blob garbage collection (γ₂'s caps UI may include a "purge orphans" action)
|
||||||
|
- Streaming/chunked uploads for very large files (>50 MB) — current design holds full plaintext + ciphertext in memory simultaneously; fine for typical use
|
||||||
|
- Resumable uploads after network failure — γ₁ retry is "user clicks again"
|
||||||
|
|
||||||
|
## Open questions deferred to plan
|
||||||
|
|
||||||
|
- **Document primary_attachment "change" UX:** clicking the `↑ change` button in edit mode replaces the primary. Does it (a) immediately delete the old blob, or (b) defer the delete until form save (matching standard attachment-removal lifecycle)? Plan ships (b) — consistent with the rest, lower risk if user "changes their mind" mid-edit.
|
||||||
|
- **Image preview thumbnail size:** 16×16 may render images as illegible blurs for portrait-orientation files. Plan ships 16×16 with `object-fit: cover` (centered crop); if user feedback wants 24×24 we adjust. The signature-block thumbs (48×60) use `object-fit: contain` to show the full image silhouette.
|
||||||
|
- **Per-vault size sum computation:** computing `sum(attachment.size)` across all manifest summaries on every upload is O(n_items × n_attachments_per_item). For vaults with >1000 items this could be ~10-100 ms. Plan: compute once at unlock, cache in popup state, increment on add / decrement on remove. Re-compute fresh from manifest on sync.
|
||||||
|
- **Filename collisions:** two attachments with the same filename on the same item — render both rows with the same name? Plan: yes; the underlying ID disambiguates them; the user sees two `screenshot.png` rows and can ✕ either one. Future polish: append `(2)` suffix to display name.
|
||||||
|
- **Download filename sanitization:** user-supplied filename goes directly to `chrome.downloads.download({filename})`. Chrome strips path separators automatically; Firefox does the same. Plan: trust the browser sanitization; no extra escaping in popup.
|
||||||
|
- **Error toast UX:** `humanizeError()` already exists in popup-side error handling per α design (spec referenced this). Plan: reuse `humanizeError(resp.error)` for upload/download failures.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Generator UX redesign + adjacent popup polish — design
|
||||||
|
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Scope:** Replace the right-anchored popover that opens from the password generator trigger with an inline panel that lives inside the form. Swap the "gen" text button for a ✨ icon button. Tighten the label/affordance treatment in the touched screens (login form + vault settings) along the way. Backgrounds, palette, and other unrelated UI stay untouched.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The current popover (β₂, commit `8a16482`) positions itself by anchoring its left edge to the trigger button's left edge, but the trigger sits on the right side of the password input row. Combined with the popover's `min-width: 300px` inside a 360 px Chrome popup, the popover always overflows the popup boundary by ~180–220 px. In manual testing it appears as a clipped card with cut-off labels and inaccessible buttons.
|
||||||
|
|
||||||
|
A surgical clamp-fix (~10 lines) would patch the symptom but leave the underlying UX awkward — even when fully visible, the popover floats over the form, hides what you were filling out, and crams two primary actions ("save default" + "use this value") next to each other. The user's feedback was explicit: "we may gotta plan some ui overhauls here, like an emoji instead of 'gen' and a cleaner UI approach for sure." This redesign replaces the popover pattern entirely instead of patching it.
|
||||||
|
|
||||||
|
## Visual identity
|
||||||
|
|
||||||
|
### Trigger button
|
||||||
|
|
||||||
|
- **Icon:** ✨ (U+2728 sparkles emoji). Reads as "auto-generate / freshly minted." Visually rhymes with the sparkle dot on the new logo's gem (commit `a3f13fd`).
|
||||||
|
- **Color:** deep gold `#7c5719` background, `#fff3cf` text — matches primary-button styling from the palette refresh.
|
||||||
|
- **Hover state:** background `#aa812a` (mid gold).
|
||||||
|
- **Active state** (panel open): background `#aa812a` (visually distinct from idle so the user can tell at a glance whether the panel is open).
|
||||||
|
- **Layout:** stays in the existing `.inline-row` pattern next to the password input; replaces the current `<button class="btn" id="gen-btn">gen</button>` with `<button class="gen-trigger" id="gen-btn" aria-expanded="false">✨</button>`.
|
||||||
|
- **Tooltip:** `title="generate password"` for hover.
|
||||||
|
- **Width:** ~38 px (single emoji glyph fits without padding noise).
|
||||||
|
|
||||||
|
### Inline panel (replaces popover)
|
||||||
|
|
||||||
|
When ✨ is clicked, a panel injects into the form's DOM **between the password row and the next form-group** (e.g., the totp-secret row). Other fields below shift down. The panel:
|
||||||
|
|
||||||
|
- Lives at the form's full available width (no positioning math, no clipping).
|
||||||
|
- Has a subtle gold border (`1px solid #aa812a`) to feel attached to the trigger.
|
||||||
|
- Auto-generates a preview the moment it opens, using `VaultSettings.generator_defaults` as the initial knob state.
|
||||||
|
|
||||||
|
Panel composition (top to bottom):
|
||||||
|
|
||||||
|
1. **Kind toggle** — pill-style two-button switch: `random` / `passphrase`. Active button: gold-bg.
|
||||||
|
2. **Common knobs (always visible):**
|
||||||
|
- For `random`: length slider (8–48, default 20), four character-class checkboxes (a-z / A-Z / 0-9 / !@#).
|
||||||
|
- For `passphrase` (BIP39): word_count slider (3–10, default 4), separator text input (1 char), capitalization radio (lower / upper / title).
|
||||||
|
3. **Preview row** — generated value in monospace gold (`#f1cf6e`), with a `↻` regenerate button.
|
||||||
|
4. **`more ▾` disclosure** — when expanded, shows the rarely-used knobs:
|
||||||
|
- For `random`: symbol charset (`safe` / `full` toggle).
|
||||||
|
- For `passphrase`: nothing extra (separator and capitalization moved to common).
|
||||||
|
- For both: an empty placeholder when no advanced knobs apply (so the disclosure always renders for consistency, even if collapsed-only).
|
||||||
|
5. **Action row:**
|
||||||
|
- **`↑ save these as default`** — small underlined link, left-aligned, `#8b949e` color → `#d2ab43` on hover. Writes current knobs to `VaultSettings.generator_defaults` via the existing `update_vault_settings` message; shows a brief "saved" toast next to the link; panel stays open. **Demoted from primary button** because most of the time the user just wants this password, not to change global defaults.
|
||||||
|
- **`cancel`** — secondary button (transparent bg, gray border).
|
||||||
|
- **`use`** — primary CTA: gold bg `#7c5719`, `#fff3cf` text. Commits the current preview value into the password input and closes the panel.
|
||||||
|
|
||||||
|
### Adjacent polish (scope B)
|
||||||
|
|
||||||
|
Touched only in screens we're already modifying (login form + vault settings):
|
||||||
|
|
||||||
|
- **Form labels:** `.label` class drops `text-transform: uppercase` and reduces `letter-spacing` from `0.5px` to `0.02em`. Lowercase labels match the panel's knob labels and feel less shouty. Font weight goes 600 → 500 for slightly less visual weight; color stays `#8b949e`.
|
||||||
|
- **Required marker:** the existing `*` next to required-field labels picks up gold (`#aa812a`) instead of inheriting label gray, so it actually reads as a marker.
|
||||||
|
- **Button styles:** primary form buttons (cancel/save at the bottom of the login form) already use the palette refresh; nothing to change there.
|
||||||
|
|
||||||
|
These polish changes apply to ALL form labels in the login form and vault settings (not just the password row), since the `.label` class is shared. Other forms that use `.label` (SecureNote, Identity, Card, Key, Totp, Document-coming-soon) will pick up the lowercase treatment automatically — that's a deliberate choice, not a side effect: the CAPS LOCK feel was a project-wide rough edge that's worth fixing in this slice.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
| Trigger | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| click ✨ | toggle panel open/closed; auto-generate on first open using saved defaults |
|
||||||
|
| click ↻ | regenerate preview (no commit) |
|
||||||
|
| change a knob | debounced auto-regenerate (150 ms — same as existing) |
|
||||||
|
| click `use` | commit current preview into password field, close panel |
|
||||||
|
| click `cancel` | close panel without committing; password field unchanged |
|
||||||
|
| click `↑ save these as default` | write current knobs to `VaultSettings.generator_defaults`; show toast; panel stays open |
|
||||||
|
| press Escape (when panel open) | close panel without committing |
|
||||||
|
| click ✨ again while panel open | close panel (no commit) |
|
||||||
|
|
||||||
|
The panel does NOT close on click-outside. The user might want to drag from the panel to verify the value or copy it before clicking `use`; closing on click-outside makes that fragile. Escape and explicit cancel/use are the dismissal paths.
|
||||||
|
|
||||||
|
## Vault settings adaptation
|
||||||
|
|
||||||
|
The vault settings screen currently has a `<button id="configure-gen">configure ▾</button>` next to a generator-summary text line. After redesign:
|
||||||
|
|
||||||
|
- The "configure ▾" button becomes a ✨ button matching the login form trigger.
|
||||||
|
- When clicked, the same inline panel renders **inside the vault-settings "generator" section** (not as a popover).
|
||||||
|
- One difference from the login-form context: the action row drops the `cancel` and `use` buttons since there's no password input to fill — instead, the panel is purely for inspecting/configuring defaults. The `↑ save these as default` link becomes the only action in this context, and ✨ closes the panel just like in the login form.
|
||||||
|
- The generator preview text line (`generatorSummary(...)`) stays above the panel even when expanded — it serves as a "current default" reference.
|
||||||
|
|
||||||
|
## Files affected
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
- **`extension/src/popup/components/generator-popover.ts`** — major rewrite. Probably gets renamed to `generator-panel.ts` (cleaner semantics). Same module, different positioning (inline DOM injection vs absolute-positioned popover) and different action set per context.
|
||||||
|
- **`extension/src/popup/components/types/login.ts`** — replace `gen-btn` text content with ✨; update click handler to call the renamed module; drop the standalone close-on-blur logic if any.
|
||||||
|
- **`extension/src/popup/components/settings-vault.ts`** — replace `configure-gen` button content with ✨; update click handler; render the inline panel in place rather than calling the popover open.
|
||||||
|
- **`extension/src/popup/styles.css`** — add `.gen-trigger` rule (button styling); add `.gen-panel` and child rules (replacing `.generator-popover` rules). Modify `.label` rule to drop uppercase and tighten letter-spacing/weight; modify `.label .req` (or equivalent for the `*`) to gold. Remove the `.generator-popover` rules entirely once the new panel works (no need to keep old popover CSS around).
|
||||||
|
|
||||||
|
### Renamed
|
||||||
|
|
||||||
|
- `extension/src/popup/components/generator-popover.ts` → `extension/src/popup/components/generator-panel.ts`. Test file follows: `__tests__/generator-popover.test.ts` → `__tests__/generator-panel.test.ts`. Update imports in `login.ts`, `settings-vault.ts`, and the test file accordingly. Sequencing decision (git-mv first vs rewrite first) noted in open questions.
|
||||||
|
|
||||||
|
### Updated tests
|
||||||
|
|
||||||
|
- **`extension/src/popup/components/__tests__/generator-popover.test.ts`** (renamed): existing 7 tests cover knob → message-shape behavior. Most should survive verbatim — they're DOM-level, not positioning-level. Update test setup to mount the panel inline (in a parent container) rather than asserting on `document.body` children. Add 2–3 new tests:
|
||||||
|
- Panel opens via aria-expanded toggling on the trigger
|
||||||
|
- Panel auto-generates on first open
|
||||||
|
- Escape key closes the panel
|
||||||
|
|
||||||
|
### Markup unchanged but new selectors
|
||||||
|
|
||||||
|
- The `.inline-row` pattern in login form stays. Just the button content/styling changes.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] Clicking ✨ on the login form opens an inline panel below the password row.
|
||||||
|
- [ ] Panel auto-generates a preview using current `VaultSettings.generator_defaults`.
|
||||||
|
- [ ] Knob changes debounce-regenerate; ↻ button forces a regenerate.
|
||||||
|
- [ ] `use` button commits preview into password input and closes panel.
|
||||||
|
- [ ] `cancel` button closes panel without committing.
|
||||||
|
- [ ] Escape key closes panel without committing.
|
||||||
|
- [ ] Clicking ✨ again while panel open closes it.
|
||||||
|
- [ ] `↑ save these as default` link writes to `VaultSettings.generator_defaults`; toast appears; panel stays open.
|
||||||
|
- [ ] Vault settings ✨ button opens the same panel inline (no popover); `↑ save these as default` is the only action; ✨ toggles closed.
|
||||||
|
- [ ] All form labels in login + vault settings are lowercase with reduced letter-spacing.
|
||||||
|
- [ ] Required-field `*` marker is gold (`#aa812a`).
|
||||||
|
- [ ] No element overflows the popup right edge in any state.
|
||||||
|
- [ ] `bun run test` passes (existing 7 generator tests survive the rename + 2-3 new tests added → ~9–10 generator-panel tests; total still around 124–127).
|
||||||
|
- [ ] `bunx tsc --noEmit` clean.
|
||||||
|
- [ ] `bun run build:all` clean (Chrome + Firefox).
|
||||||
|
- [ ] No new automated tests for the visual polish (label casing, gold `*`) — visually verified.
|
||||||
|
- [ ] Manual: walk through both contexts (login form + vault settings) on Chrome and Firefox.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The capture-prompt and ack-prompt content scripts (still use their own button styling — no change here).
|
||||||
|
- The setup tab's strength-bar / advice-block (touched in logo-refresh palette swap; nothing more to do).
|
||||||
|
- Other popup forms beyond their `.label` class picking up the lowercase treatment automatically (no per-type form rework).
|
||||||
|
- Generator output strength visualization (zxcvbn meter inside the panel) — could be a future polish but not now.
|
||||||
|
- Multi-preview / "show 3 candidates" pattern — keeping the single-preview + regenerate flow.
|
||||||
|
- Animation/transitions on panel open-close — purely instant for now (a fade or slide-down can be added later as polish without breaking anything).
|
||||||
|
- Click-outside-to-close — explicitly NOT included (see Behavior section reasoning).
|
||||||
|
|
||||||
|
## Open questions deferred to plan
|
||||||
|
|
||||||
|
- **Module rename ordering:** is it cleaner to (a) rewrite in-place keeping the `generator-popover.ts` filename then rename in a follow-up, or (b) git-mv first then rewrite? Plan ships (b) — git-mv preserves history, reviewers see "rename + edits" cleanly.
|
||||||
|
- **Test mounting strategy:** existing tests `document.body.appendChild(host)` then assert. New panel mounts inside a parent. Plan: tests create a parent div, pass it as the mount target to a new `openGeneratorPanel(opts)` signature that takes `{ parent, anchor, initial, onPicked, onCancel }`. The login-form caller passes the form element as `parent`.
|
||||||
|
- **The "more ▾" placeholder:** for passphrase mode, all knobs are common and there's nothing in advanced. Plan: render the disclosure with text "(no advanced options for passphrase)" when expanded, OR hide the disclosure entirely in passphrase mode. Plan ships the hide-when-empty option — less visual noise.
|
||||||
|
- **`save default` toast:** existing toast infrastructure in popup? If yes, reuse. If not, the smallest toast = a 1.5s fade-in/fade-out span next to the `↑ save these as default` link saying "✓ saved". Plan picks based on what already exists.
|
||||||
|
- **Vault-settings panel ✨ — when no defaults exist:** the very first time a vault is created, `VaultSettings.generator_defaults` should already be initialized (it is, per β₂). Confirm and document.
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Logo refresh + extension palette shift — design
|
||||||
|
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Scope:** Replace the existing arched-niche-with-blue-gem logo with a reliquary-faithful round chapel theca, and shift the extension's primary accent from GitHub-blue to a burnished gold that matches the new logo. Backgrounds and CLI feel preserved.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The current logo reads as "modern shrine with a blue diamond" — visually correct in concept (a vessel that holds something precious) but blue-techy enough that the project's name (*Relicario* — Spanish/Italian for *reliquary*) no longer comes through. The user wants more catholic-reliquary authenticity (gold, deep red, decorative finial) without the cross — closer to the user-supplied references of round-chapel theca reliquaries.
|
||||||
|
|
||||||
|
The popup currently uses GitHub's dark-blue accent palette throughout. Once the logo shifts to gold, leaving the popup's blue accents in place would create visual whiplash between the toolbar icon and the popup body. The palette shift converts blue → gold and tunes the danger red toward the logo's theca tone, while keeping the dark backgrounds, monospace-ish text, and CLI restraint that define the project's voice.
|
||||||
|
|
||||||
|
## Visual identity
|
||||||
|
|
||||||
|
### Silhouette
|
||||||
|
|
||||||
|
Round chapel-style theca with a fleur-de-lis finial and a compact pedestal. Inspired by user-supplied references of monstrance-style theca reliquaries (round display window in a gold ring, on a small turned base). No cross — the catholic visual vocabulary is preserved through the fleur-de-lis, the deep-red theca, and the burnished gold body.
|
||||||
|
|
||||||
|
Composition (master, 220 × 240 viewBox):
|
||||||
|
|
||||||
|
- **Pedestal** — y=202 to y=230 (28 units total, ~60% of the original draft):
|
||||||
|
- Stem cap: ellipse (cx=110, cy=202, rx=18, ry=4)
|
||||||
|
- Stem column: rect (x=98, y=202, w=24, h=12) with a darker knurl ring mid-stem (`ellipse cx=110 cy=208 rx=14 ry=3`)
|
||||||
|
- Base plate: rect (x=78, y=212, w=64, h=14, rx=2)
|
||||||
|
- Foot ring: ellipse (cx=110, cy=226, rx=44, ry=5)
|
||||||
|
- **Body** — circle (cx=110, cy=130, r=72) in gold; inner bezel ring (r=60) in deep gold; deep-red theca (r=56) with radial gradient
|
||||||
|
- Subtle upper-left bevel highlight: arc-stroke at top-left of body
|
||||||
|
- Soft glass glint on the theca: white ellipse @ 14% opacity, rotated −30°
|
||||||
|
- **Asterisk gem** (the "relic" inside the theca):
|
||||||
|
- 6 arms at 60° increments
|
||||||
|
- Each arm: lozenge (base width 8, slight bulge mid-arm, pointed tip at 36 from center)
|
||||||
|
- **Pinwheel facet split** — every arm is bright (`#f5d97a`) on the CCW side, dark (`#8a5e1c`) on the CW side
|
||||||
|
- Center hex facet (mid gold) + sparkle dot (off-white) for that "cut gem" read
|
||||||
|
- **Hinge collar** — small rect (x=98, y=50, w=24, h=10, rx=2) with a horizontal accent line, where the body meets the fleur
|
||||||
|
- **Fleur-de-lis** (rooted into the hinge collar, occupies y=−16 to y=50):
|
||||||
|
- Thicker stem (7 wide, 12 tall)
|
||||||
|
- Tie-band: rect 32 × 7, with a darker knot rectangle in the middle
|
||||||
|
- Center petal: tall teardrop with an inner shadow line and a small pearl at the tip
|
||||||
|
- Side petals: S-curve outward with a small dark accent on the outer curl
|
||||||
|
- Sized so the fleur is ~35% the body's diameter — present but doesn't dominate
|
||||||
|
|
||||||
|
### 16 px treatment (favicon)
|
||||||
|
|
||||||
|
Pedestal is dropped entirely — it would compress to 1–2 pixels of indistinct gold noise. The 16 px form is the bare medallion: round body + fleur on top.
|
||||||
|
|
||||||
|
ViewBox 16 × 16:
|
||||||
|
|
||||||
|
- Body: circle (cx=8, cy=9, r=6.5) gold; inner red theca (r=4.8)
|
||||||
|
- Gem: three crossing 1.2 px lines (vertical + two diagonals) in bright gold + a 0.7 px sparkle dot — reads as `*` at all zoom levels
|
||||||
|
- Fleur: three triangular tips above the body (center peak at y=0, side wings peaking at y=1, all bases on y=2.5)
|
||||||
|
|
||||||
|
### Palette
|
||||||
|
|
||||||
|
Backgrounds and text colors are unchanged from the existing GitHub-dark base — preserves the CLI feel.
|
||||||
|
|
||||||
|
| Use | Old | New |
|
||||||
|
|-----|-----|-----|
|
||||||
|
| Logo gold (bright) | n/a | `#d2ab43` |
|
||||||
|
| Logo gold (mid) | n/a | `#aa812a` |
|
||||||
|
| Logo gold (deep) | n/a | `#7c5719` |
|
||||||
|
| Logo gold (highlight) | n/a | `#f5d97a` |
|
||||||
|
| Logo gold (shadow) | n/a | `#8a5e1c` |
|
||||||
|
| Logo red (theca bright) | n/a | `#9a1a1a` |
|
||||||
|
| Logo red (theca shadow) | n/a | `#3a0a0a` |
|
||||||
|
| **Primary button bg** | `#1f6feb` | `#7c5719` |
|
||||||
|
| **Primary button hover** | `#388bfd` | `#aa812a` |
|
||||||
|
| **Primary text / link** | `#58a6ff` | `#d2ab43` |
|
||||||
|
| **Focus ring / outline** | `#58a6ff` (often @ 30%) | `#aa812a` (@ 40%) |
|
||||||
|
| **Selected row tint** | `rgba(88,166,255,0.12)` | `rgba(170,129,42,0.11)` |
|
||||||
|
| **Selected row left-border** | `#58a6ff` / `#1f6feb` | `#aa812a` |
|
||||||
|
| **Danger fg** | `#f85149` | `#ab2b20` |
|
||||||
|
| **Danger emphasis bg** | `#da3633` | `#791111` |
|
||||||
|
| **Sig-block --blue** | `#1f6feb` | `#aa812a` (renamed `--gold`) |
|
||||||
|
| **TOTP ring stroke** | `#58a6ff` | `#d2ab43` |
|
||||||
|
| **Backgrounds** | `#0d1117` / `#161b22` / `#21262d` / `#30363d` | unchanged |
|
||||||
|
| **Text fg / muted / dim** | `#c9d1d9` / `#8b949e` / `#6e7681` | unchanged |
|
||||||
|
| **Status success** | `#3fb950` | unchanged |
|
||||||
|
| **Status warning** | `#d29922` | unchanged |
|
||||||
|
|
||||||
|
The B/C midpoint gold ramp comes from RGB midpoints between two earlier candidate palettes (a "burnished" 10%-darker variant and an "antique" 20%-darker variant).
|
||||||
|
|
||||||
|
## Files affected
|
||||||
|
|
||||||
|
### New / replaced asset files
|
||||||
|
|
||||||
|
- **`extension/icons/relicario-logo.svg`** — replace entirely with the new master (220 × 240 viewBox, gold/red).
|
||||||
|
- **`extension/icons/relicario-logo-16.svg`** — replace entirely with the bare-medallion 16 px version (16 × 16 viewBox).
|
||||||
|
- **`extension/icons/icon-16.png`** — regenerate from `relicario-logo-16.svg` via ImageMagick.
|
||||||
|
- **`extension/icons/icon-48.png`** — regenerate from `relicario-logo.svg` (the master) at 48 × 48.
|
||||||
|
- **`extension/icons/icon-128.png`** — regenerate from `relicario-logo.svg` at 128 × 128.
|
||||||
|
|
||||||
|
### Code files touching colors
|
||||||
|
|
||||||
|
- **`extension/src/popup/styles.css`** — bulk find-and-replace of the blue/red hex values per the table above. ~20 hits.
|
||||||
|
- **`extension/src/popup/components/types/login.ts`** — line 50: link color `#58a6ff` → `#d2ab43`.
|
||||||
|
- **`extension/src/popup/components/types/totp.ts`** — line 60: TOTP ring stroke `#58a6ff` → `#d2ab43`.
|
||||||
|
- **`extension/src/popup/components/generator-popover.ts`** — line 283: validation error color `#f85149` → `#ab2b20`.
|
||||||
|
- **`extension/src/popup/components/settings.ts`** — lines 28, 52, 53: blacklist-remove `#f85149` → `#ab2b20`; bar/toast active state `#1f6feb` → `#7c5719`.
|
||||||
|
- **`extension/src/content/capture.ts`** — lines 184, 195: hostname text `#58a6ff` → `#d2ab43`; save button bg `#1f6feb` → `#7c5719`.
|
||||||
|
- **`extension/src/content/icon.ts`** — lines 73, 203: ack-prompt button bg `#1f6feb` → `#7c5719`; title color `#58a6ff` → `#d2ab43`.
|
||||||
|
- **`extension/setup.html`** — strength-bar very-weak `#f85149` → `#ab2b20`; advice block left-border `#1f6feb` → `#aa812a`; match/test result fail `#f85149` → `#ab2b20`. (Strength bar's other gradient stops should be re-tuned to match — e.g., weak/medium/strong should still progress visually.)
|
||||||
|
|
||||||
|
### Test files
|
||||||
|
|
||||||
|
No new tests required — palette + logo are visual changes. Existing 124 Vitest + 155 Rust tests should remain green throughout (the changes are CSS hex strings + SVG markup; no behavior changes).
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ] `extension/icons/relicario-logo.svg` matches the master design (gold body, red theca, asterisk gem with pinwheel facets, fleur-de-lis finial, compact pedestal).
|
||||||
|
- [ ] `extension/icons/relicario-logo-16.svg` matches the bare-medallion 16 px design (no pedestal).
|
||||||
|
- [ ] `extension/icons/icon-16.png`, `icon-48.png`, `icon-128.png` regenerated from the SVGs and visually correct at the toolbar.
|
||||||
|
- [ ] `git grep -nE '#(58a6ff|1f6feb|388bfd|f85149|da3633)' extension/` returns zero hits in `src/` and `setup.html` (it can still appear in `node_modules/`, `dist/`, `dist-firefox/` — those don't matter).
|
||||||
|
- [ ] `bun run build:all` passes for both Chrome and Firefox bundles.
|
||||||
|
- [ ] `bun run test` passes (124/124).
|
||||||
|
- [ ] `cargo test --workspace` passes (155/155).
|
||||||
|
- [ ] Manual smoke check: load `extension/dist/` in Chrome → toolbar icon shows the new logo → open popup → primary buttons (`+ New`, `autofill`, `save`) are gold-bg → focus rings on inputs are gold → selected list row has gold left-border + tint → danger buttons (trash, delete) are theca-red → TOTP countdown ring is gold.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The capture-prompt and ack-prompt content-script DOM (closed Shadow DOM): inline colors get updated, but no layout/UX changes.
|
||||||
|
- New icon sizes (256, 512, etc.). Current set is 16/48/128, matching `manifest.json`.
|
||||||
|
- Rendering paths that already use gold-friendly colors (success green, warning yellow). Those stay.
|
||||||
|
- Logo sizes for App Store / web favicon / social cards. None of those exist yet for this project; defer.
|
||||||
|
|
||||||
|
## Open questions deferred to plan
|
||||||
|
|
||||||
|
- **Setup-page strength bar color ramp:** β₀ used `#f85149 → #d29922 → #3fb950` for very-weak → medium → strong. The danger red is now `#ab2b20`; do we keep the warning yellow / success green unchanged for the gradient (mixed-temperature ramp), or also shift them toward the warmer family (e.g. amber instead of yellow)? Plan defaults to keeping yellow/green untouched — the bar's role is functional accessibility, and the universal red→yellow→green semantics are stronger than aesthetic coherence.
|
||||||
|
- **Sig-block class rename:** existing CSS classes are `sig-block--blue`, `sig-block--red`. After the swap, `--blue` no longer matches the rendered color. Plan options: (a) keep names, accept the mismatch (zero risk, semantically wrong), (b) rename to `--gold` / `--red` (touches all consumers — cheap to do and worth doing). Plan ships (b).
|
||||||
|
- **PNG regeneration tool:** project memory specifies ImageMagick (`magick`) over `rsvg-convert`. Plan will use `magick -background none -density 384 input.svg -resize 128x128 output.png` (and 48, 16) per memory.
|
||||||
|
- **WAR / CSP:** SVG files are loaded as extension-origin assets, no MV3 web-accessible-resources changes needed. Confirmed by inspecting current manifest (WAR is empty).
|
||||||
|
|
||||||
|
## Master SVG (full source)
|
||||||
|
|
||||||
|
Embedded for reference — implementation plan will copy this verbatim into `extension/icons/relicario-logo.svg`.
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="redTheca" cx="0.4" cy="0.35">
|
||||||
|
<stop offset="0%" stop-color="#9a1a1a"/>
|
||||||
|
<stop offset="100%" stop-color="#3a0a0a"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldRing" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#d2ab43"/>
|
||||||
|
<stop offset="50%" stop-color="#f5d97a"/>
|
||||||
|
<stop offset="100%" stop-color="#7c5719"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldHi" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#fde9a8"/>
|
||||||
|
<stop offset="100%" stop-color="#d2ab43"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Pedestal (compact) -->
|
||||||
|
<ellipse cx="110" cy="226" rx="44" ry="5" fill="url(#goldRing)"/>
|
||||||
|
<rect x="78" y="212" width="64" height="14" rx="2" fill="url(#goldRing)"/>
|
||||||
|
<rect x="98" y="202" width="24" height="12" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="110" cy="208" rx="14" ry="3" fill="#7c5719"/>
|
||||||
|
<ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/>
|
||||||
|
|
||||||
|
<!-- Body, bezel, theca -->
|
||||||
|
<circle cx="110" cy="130" r="72" fill="url(#goldRing)"/>
|
||||||
|
<path d="M 110 58 A 72 72 0 0 0 38 130" stroke="#fde9a8" stroke-width="2" fill="none" opacity="0.6"/>
|
||||||
|
<circle cx="110" cy="130" r="60" fill="#7c5719"/>
|
||||||
|
<circle cx="110" cy="130" r="56" fill="url(#redTheca)"/>
|
||||||
|
<ellipse cx="86" cy="108" rx="16" ry="7" fill="#ffffff" opacity="0.14" transform="rotate(-30 86 108)"/>
|
||||||
|
|
||||||
|
<!-- Asterisk gem with pinwheel facets -->
|
||||||
|
<g transform="translate(110, 130)">
|
||||||
|
<g transform="rotate(0)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(60)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(120)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(180)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(240)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(300)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<polygon points="0,-6 5.2,-3 5.2,3 0,6 -5.2,3 -5.2,-3" fill="#d2ab43" stroke="#7c5719" stroke-width="0.6"/>
|
||||||
|
<circle cx="-1.5" cy="-2" r="1.4" fill="#fff3cf"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Hinge collar -->
|
||||||
|
<rect x="98" y="50" width="24" height="10" rx="2" fill="url(#goldRing)"/>
|
||||||
|
<line x1="100" y1="55" x2="120" y2="55" stroke="#7c5719" stroke-width="0.8"/>
|
||||||
|
|
||||||
|
<!-- Fleur-de-lis -->
|
||||||
|
<g transform="translate(110, 50)">
|
||||||
|
<rect x="-3.5" y="-12" width="7" height="12" fill="url(#goldRing)"/>
|
||||||
|
<rect x="-16" y="-18" width="32" height="7" rx="1.5" fill="url(#goldRing)"/>
|
||||||
|
<rect x="-3" y="-19" width="6" height="9" rx="0.8" fill="#7c5719"/>
|
||||||
|
<path d="M 0 -18 Q -8 -36, -4 -54 Q -1 -62, 0 -64 Q 1 -62, 4 -54 Q 8 -36, 0 -18 Z" fill="url(#goldRing)"/>
|
||||||
|
<path d="M 0 -22 Q -2.5 -36, 0 -52 Q 2.5 -36, 0 -22 Z" fill="#7c5719" opacity="0.55"/>
|
||||||
|
<circle cx="0" cy="-66" r="2.5" fill="url(#goldHi)"/>
|
||||||
|
<path d="M -4 -18 Q -22 -22, -26 -38 Q -22 -50, -16 -50 Q -16 -38, -10 -32 Q -6 -28, -4 -28 Z" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="-25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(-20 -25 -44)"/>
|
||||||
|
<path d="M 4 -18 Q 22 -22, 26 -38 Q 22 -50, 16 -50 Q 16 -38, 10 -32 Q 6 -28, 4 -28 Z" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(20 25 -44)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16 px SVG (full source)
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="redThecaSm" cx="0.4" cy="0.35">
|
||||||
|
<stop offset="0%" stop-color="#9a1a1a"/>
|
||||||
|
<stop offset="100%" stop-color="#3a0a0a"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldRingSm" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#d2ab43"/>
|
||||||
|
<stop offset="50%" stop-color="#f5d97a"/>
|
||||||
|
<stop offset="100%" stop-color="#7c5719"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Body + theca -->
|
||||||
|
<circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/>
|
||||||
|
<circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/>
|
||||||
|
|
||||||
|
<!-- Asterisk-as-3-bars -->
|
||||||
|
<g transform="translate(8, 9)" stroke="#f5d97a" stroke-width="1.2" stroke-linecap="round">
|
||||||
|
<line x1="0" y1="-3" x2="0" y2="3"/>
|
||||||
|
<line x1="-2.6" y1="-1.5" x2="2.6" y2="1.5"/>
|
||||||
|
<line x1="-2.6" y1="1.5" x2="2.6" y2="-1.5"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="8" cy="9" r="0.7" fill="#fff3cf"/>
|
||||||
|
|
||||||
|
<!-- Fleur (3 tips) -->
|
||||||
|
<path d="M 8 0 L 7.2 2.5 L 8.8 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
<path d="M 5.6 2.5 L 6.5 1 L 7.3 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
<path d="M 10.4 2.5 L 9.5 1 L 8.7 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# Plan 1C-γ₂: Device registration + Trash + Field history + Attachment caps — design
|
||||||
|
|
||||||
|
**Date:** 2026-04-26
|
||||||
|
**Scope:** Add device registration during setup, device management UI, trash view with restore/purge (including orphan blob cleanup), per-item field history view, and a single attachment-cap setting in vault settings.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The Rust core already supports soft-delete/restore (`Item::soft_delete`, `Item::restore`, `Item::is_trashed`), field history capture (auto-tracked for Password/Concealed/Totp fields in `Item::field_history`), and attachment caps (`VaultSettings::attachment_caps`). The CLI has device management via ed25519 keypairs (`device add/list/revoke`). What's missing is the extension surface: a way to register the extension as a device, view/revoke devices, browse and act on trashed items, view password history, and configure the attachment size limit.
|
||||||
|
|
||||||
|
γ₂ completes Plan 1C by exposing these already-implemented core capabilities in the extension UI.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Commit signing with device key — keypair is generated and stored for future use, but no operations are signed yet.
|
||||||
|
- Bulk trash operations (select-all, empty-selected) — single-item restore + "empty all" only.
|
||||||
|
- Field history editing/deletion — view-only.
|
||||||
|
- Manual orphan blob purge button — orphans are cleaned automatically when emptying trash.
|
||||||
|
- Exposing all four attachment caps — only `per_attachment_max_bytes` is user-configurable; others use sensible defaults.
|
||||||
|
|
||||||
|
## Visual identity
|
||||||
|
|
||||||
|
### Device name step in setup wizard
|
||||||
|
|
||||||
|
After passphrase + reference image, a new step appears:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name this device
|
||||||
|
|
||||||
|
This helps you identify which devices have access to your vault.
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Chrome on Linux │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
|
||||||
|
[ continue ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Auto-suggested default: `"{browser} on {platform}"` (e.g., "Chrome on Linux", "Firefox on macOS")
|
||||||
|
- User can edit the name or accept the default
|
||||||
|
- "Continue" generates keypair, stores private key locally, commits pubkey to `devices.json`
|
||||||
|
|
||||||
|
### Device management screen
|
||||||
|
|
||||||
|
Entry point: "Devices" link in popup navigation (gear icon row alongside Settings).
|
||||||
|
|
||||||
|
```
|
||||||
|
← back devices
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Chrome on Linux ← you │
|
||||||
|
│ added 3d ago │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Firefox on MacBook revoke │
|
||||||
|
│ added 2w ago │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ CLI revoke │
|
||||||
|
│ added 1mo ago │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- "← you" badge on current device (matched via `device_name` in `chrome.storage.local`)
|
||||||
|
- Current device row has no revoke button (can't revoke self)
|
||||||
|
- Revoke shows confirm: "Revoke {name}? This device will no longer be authorized."
|
||||||
|
- Commits `"device: revoke {name}"` on confirm
|
||||||
|
|
||||||
|
**Unregistered device banner:** If `device_private_key` is missing from local storage but vault exists, show:
|
||||||
|
```
|
||||||
|
⚠ This device is not registered
|
||||||
|
[ Register this device ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trash screen
|
||||||
|
|
||||||
|
Entry point: "Trash" link in popup navigation.
|
||||||
|
|
||||||
|
```
|
||||||
|
← back trash
|
||||||
|
|
||||||
|
3 items · oldest auto-purges in 45d
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 🔑 Old Bank Login │
|
||||||
|
│ trashed 2d ago restore │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 📝 Temp Note │
|
||||||
|
│ trashed 5d ago restore │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 💳 Expired Card │
|
||||||
|
│ trashed 12d ago restore │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
|
||||||
|
[ empty trash ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- List from manifest where `trashed_at != null`, sorted newest-trashed first
|
||||||
|
- Type icon + title per row (same as main item list)
|
||||||
|
- "restore" clears `trashed_at`, updates manifest, commits
|
||||||
|
- Header shows count + days until oldest item auto-purges (based on `trash_retention`)
|
||||||
|
- "empty trash" confirms: "Permanently delete 3 items? This cannot be undone."
|
||||||
|
- Empty trash also scans for orphan blobs (attachments not referenced by any item) and deletes them
|
||||||
|
- Single commit for the whole operation: `"trash: purge N items + M orphan blobs"`
|
||||||
|
- Empty state: "Trash is empty"
|
||||||
|
|
||||||
|
### Field history screen
|
||||||
|
|
||||||
|
Entry point: "View history" link on item detail (only shown if `field_history` is non-empty).
|
||||||
|
|
||||||
|
```
|
||||||
|
← back to item password history
|
||||||
|
|
||||||
|
GitHub Login
|
||||||
|
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ •••••••••••• current │
|
||||||
|
│ set 2d ago [ 📋 ] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ •••••••••••• │
|
||||||
|
│ changed 3w ago [ 📋 ] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ •••••••••••• │
|
||||||
|
│ changed 2mo ago [ 📋 ] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Shows history for all tracked fields (Password, Concealed, Totp)
|
||||||
|
- **Current value** comes from the item's field itself (not `field_history`), marked "current", timestamp = item's `modified`
|
||||||
|
- **Historical values** come from `field_history` entries
|
||||||
|
- Values masked by default; click row to reveal
|
||||||
|
- Copy button per entry
|
||||||
|
- Sorted newest-first (current always first)
|
||||||
|
- If multiple tracked fields exist, group by field name with section headers
|
||||||
|
|
||||||
|
### Attachment caps in vault settings
|
||||||
|
|
||||||
|
New section after "autofill origins" in vault settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
attachments
|
||||||
|
|
||||||
|
max file size [ 10 MB ▾ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Dropdown with presets: 5 MB, 10 MB (default), 25 MB, 50 MB
|
||||||
|
- Updates `vault_settings.attachment_caps.per_attachment_max_bytes`
|
||||||
|
- Other caps remain at defaults: `per_item_max_count: 20`, `per_vault_soft_cap_bytes: 100MB`, `per_vault_hard_cap_bytes: 500MB`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Layer 1: WASM bindings
|
||||||
|
|
||||||
|
New exports in `relicario-wasm`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Generate an ed25519 keypair for device registration.
|
||||||
|
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn generate_device_keypair() -> String;
|
||||||
|
|
||||||
|
/// Extract field history from a decrypted item.
|
||||||
|
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
||||||
|
/// where current_value is the field's present value (for the "current" row in UI)
|
||||||
|
/// and entries are historical values from field_history.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_field_history(item_json: &str) -> String;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ed25519-dalek` crate is already a dependency (used by CLI). WASM feature gate may be needed.
|
||||||
|
|
||||||
|
### Layer 2: Shared types
|
||||||
|
|
||||||
|
`extension/src/shared/types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Device {
|
||||||
|
name: string;
|
||||||
|
public_key: string; // hex-encoded ed25519 pubkey
|
||||||
|
added_at: number; // unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldHistoryEntry {
|
||||||
|
value: string;
|
||||||
|
changed_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldHistory {
|
||||||
|
field_id: string;
|
||||||
|
field_name: string;
|
||||||
|
current_value: string; // present value of the field
|
||||||
|
entries: FieldHistoryEntry[]; // historical values
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`extension/src/shared/messages.ts` — new message types:
|
||||||
|
|
||||||
|
| Message | Direction | Purpose |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| `list_devices` | popup → SW | Get `Device[]` from `devices.json` |
|
||||||
|
| `add_device` | popup → SW | Register new device (name, pubkey) |
|
||||||
|
| `revoke_device` | popup → SW | Remove device by name |
|
||||||
|
| `list_trashed` | popup → SW | Get manifest entries where `trashed_at != null` |
|
||||||
|
| `restore_item` | popup → SW | Clear `trashed_at` on item, update manifest |
|
||||||
|
| `purge_item` | popup → SW | Permanently delete single trashed item |
|
||||||
|
| `purge_all_trash` | popup → SW | Delete all trashed items + orphan blobs |
|
||||||
|
| `get_field_history` | popup → SW | Get history for an item |
|
||||||
|
|
||||||
|
### Layer 3: Service worker
|
||||||
|
|
||||||
|
**`extension/src/service-worker/devices.ts`** (NEW):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function readDevices(gitHost: GitHost): Promise<Device[]>;
|
||||||
|
export async function writeDevices(gitHost: GitHost, devices: Device[], message: string): Promise<void>;
|
||||||
|
export async function addDevice(gitHost: GitHost, device: Device): Promise<void>;
|
||||||
|
export async function revokeDevice(gitHost: GitHost, name: string): Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads/writes `.relicario/devices.json` in the vault repo.
|
||||||
|
|
||||||
|
**`extension/src/service-worker/vault.ts`** — new functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function listTrashed(manifest: Manifest): ManifestEntry[];
|
||||||
|
export async function restoreItem(gitHost: GitHost, session: SessionHandle, itemId: string): Promise<void>;
|
||||||
|
export async function purgeItem(gitHost: GitHost, itemId: string): Promise<void>;
|
||||||
|
export async function purgeAllTrash(gitHost: GitHost, session: SessionHandle, manifest: Manifest): Promise<{ itemCount: number, orphanCount: number }>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Orphan blob scan algorithm:**
|
||||||
|
|
||||||
|
1. Collect all `AttachmentRef.id` values from all non-trashed items → `Set<string> referenced`
|
||||||
|
2. List files in `attachments/` directory → `Set<string> existing`
|
||||||
|
3. Orphans = `existing - referenced`
|
||||||
|
4. Delete each orphan blob file
|
||||||
|
5. Return count for commit message
|
||||||
|
|
||||||
|
**Router handlers** in `popup-only.ts`:
|
||||||
|
|
||||||
|
- All 8 message types get handlers
|
||||||
|
- Standard sender check (popup-only)
|
||||||
|
- Return `{ ok: true, data: ... }` or `{ ok: false, error: '...', detail: '...' }`
|
||||||
|
|
||||||
|
### Layer 4: Popup
|
||||||
|
|
||||||
|
**New screens:**
|
||||||
|
|
||||||
|
- `extension/src/popup/components/trash.ts` — trash list view
|
||||||
|
- `extension/src/popup/components/devices.ts` — device management view
|
||||||
|
- `extension/src/popup/components/field-history.ts` — per-item history view
|
||||||
|
|
||||||
|
**Modified screens:**
|
||||||
|
|
||||||
|
- `extension/src/popup/components/setup-wizard.ts` — add device name step after reference image
|
||||||
|
- `extension/src/popup/components/settings-vault.ts` — add attachment caps section
|
||||||
|
- `extension/src/popup/components/item-detail.ts` — add "View history" link if history exists
|
||||||
|
- `extension/src/popup/popup.ts` — add navigation targets for trash, devices, field-history
|
||||||
|
|
||||||
|
**Navigation state:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Screen =
|
||||||
|
| 'unlock' | 'setup' | 'list' | 'detail' | 'form' | 'settings' | 'vault-settings'
|
||||||
|
| 'trash' | 'devices' | 'field-history'; // ← new
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
// existing fields...
|
||||||
|
historyItemId?: string; // for field-history screen
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Device name step flow:**
|
||||||
|
|
||||||
|
1. After reference image step, show device name input
|
||||||
|
2. On continue:
|
||||||
|
- Call WASM `generate_device_keypair()` → `{ public_key_hex, private_key_base64 }`
|
||||||
|
- Store in `chrome.storage.local`: `device_name`, `device_private_key`
|
||||||
|
- Send `add_device` message to SW with name + pubkey
|
||||||
|
- SW writes to `devices.json`, commits
|
||||||
|
3. Proceed to vault creation/unlock
|
||||||
|
|
||||||
|
**Unregistered device detection:**
|
||||||
|
|
||||||
|
On unlock success, popup checks:
|
||||||
|
- If `device_private_key` missing from local storage AND vault has `devices.json` with entries → show "not registered" banner
|
||||||
|
- Banner click triggers device registration flow (same as setup, but without passphrase/image steps)
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Unit tests (vitest + happy-dom)
|
||||||
|
|
||||||
|
**Service worker tests:**
|
||||||
|
|
||||||
|
- `devices.test.ts`: add/list/revoke, duplicate name rejection, JSON format
|
||||||
|
- `trash.test.ts`: listTrashed filter, restoreItem clears timestamp, purgeItem deletes files, orphan scan logic
|
||||||
|
|
||||||
|
**Popup tests:**
|
||||||
|
|
||||||
|
- `trash.test.ts`: renders trashed items, restore button, empty trash confirm
|
||||||
|
- `devices.test.ts`: renders device list, "you" indicator, revoke confirm
|
||||||
|
- `field-history.test.ts`: renders entries, mask/reveal toggle, copy button
|
||||||
|
- `setup-wizard.test.ts`: device name step appears, defaults correctly
|
||||||
|
|
||||||
|
**Router tests:**
|
||||||
|
|
||||||
|
- Extend `router.test.ts` with cases for all 8 new message types
|
||||||
|
- Sender check verification (reject non-popup callers)
|
||||||
|
|
||||||
|
### Manual browser test matrix
|
||||||
|
|
||||||
|
| # | Test | Chrome | Firefox |
|
||||||
|
|---|------|--------|---------|
|
||||||
|
| 1 | Setup wizard shows device name step | | |
|
||||||
|
| 2 | Device name defaults to "Chrome on Linux" (or similar) | | |
|
||||||
|
| 3 | Device list shows "← you" on current device | | |
|
||||||
|
| 4 | Revoke other device works, confirms | | |
|
||||||
|
| 5 | Trash item from detail view | | |
|
||||||
|
| 6 | Trash view shows trashed items | | |
|
||||||
|
| 7 | Restore from trash returns item to list | | |
|
||||||
|
| 8 | Empty trash purges items + orphan blobs | | |
|
||||||
|
| 9 | Field history shows after password edit | | |
|
||||||
|
| 10 | History values masked, click to reveal | | |
|
||||||
|
| 11 | Attachment cap dropdown in vault settings | | |
|
||||||
|
| 12 | Cap change persists across unlock cycles | | |
|
||||||
|
|
||||||
|
## File changes
|
||||||
|
|
||||||
|
### Rust (WASM)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `crates/relicario-wasm/src/lib.rs` | Add `generate_device_keypair`, `get_field_history` |
|
||||||
|
| `crates/relicario-wasm/Cargo.toml` | Ensure `ed25519-dalek` features for WASM |
|
||||||
|
|
||||||
|
### Extension — shared
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `extension/src/shared/types.ts` | Add `Device`, `FieldHistoryEntry`, `FieldHistory` |
|
||||||
|
| `extension/src/shared/messages.ts` | Add 8 message types |
|
||||||
|
|
||||||
|
### Extension — service worker
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `extension/src/service-worker/devices.ts` | NEW — device CRUD |
|
||||||
|
| `extension/src/service-worker/vault.ts` | Add trash/restore/purge functions |
|
||||||
|
| `extension/src/service-worker/router/popup-only.ts` | Add 8 handlers |
|
||||||
|
| `extension/src/service-worker/__tests__/devices.test.ts` | NEW |
|
||||||
|
| `extension/src/service-worker/__tests__/trash.test.ts` | NEW |
|
||||||
|
| `extension/src/service-worker/router/__tests__/router.test.ts` | Extend with new handlers |
|
||||||
|
|
||||||
|
### Extension — popup
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `extension/src/popup/components/trash.ts` | NEW |
|
||||||
|
| `extension/src/popup/components/devices.ts` | NEW |
|
||||||
|
| `extension/src/popup/components/field-history.ts` | NEW |
|
||||||
|
| `extension/src/popup/components/setup-wizard.ts` | Add device name step |
|
||||||
|
| `extension/src/popup/components/settings-vault.ts` | Add attachment caps section |
|
||||||
|
| `extension/src/popup/components/item-detail.ts` | Add "View history" link |
|
||||||
|
| `extension/src/popup/popup.ts` | Add navigation targets |
|
||||||
|
| `extension/src/popup/styles.css` | New styles for trash, devices, history |
|
||||||
|
| `extension/src/popup/components/__tests__/trash.test.ts` | NEW |
|
||||||
|
| `extension/src/popup/components/__tests__/devices.test.ts` | NEW |
|
||||||
|
| `extension/src/popup/components/__tests__/field-history.test.ts` | NEW |
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
Bottom-up by layer, with setup wizard changes near the end:
|
||||||
|
|
||||||
|
1. **WASM bindings** — `generate_device_keypair`, `get_field_history`
|
||||||
|
2. **Shared types** — `Device`, `FieldHistory*`, message types
|
||||||
|
3. **SW devices** — `devices.ts` + handlers + tests
|
||||||
|
4. **SW trash** — trash functions in `vault.ts` + handlers + tests
|
||||||
|
5. **SW field history** — handler (uses WASM binding) + tests
|
||||||
|
6. **Popup trash screen** — `trash.ts` + styles + tests
|
||||||
|
7. **Popup devices screen** — `devices.ts` + styles + tests
|
||||||
|
8. **Popup field history screen** — `field-history.ts` + tests
|
||||||
|
9. **Popup item-detail** — "View history" link
|
||||||
|
10. **Popup vault-settings** — attachment caps section
|
||||||
|
11. **Popup navigation** — wire trash + devices entry points
|
||||||
|
12. **Setup wizard** — device name step (atomic, riskiest change last)
|
||||||
|
13. **Manual browser testing** — Chrome + Firefox matrix
|
||||||
|
|
||||||
|
## Commit strategy
|
||||||
|
|
||||||
|
Direct to `main` per project convention. Each task = one commit. Do NOT push.
|
||||||
|
|
||||||
|
Tag `plan-1c-gamma2-complete` after all tasks pass + manual tests verified.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Attach existing vault — wizard split + clobber guard (v0.2.0)
|
||||||
|
|
||||||
|
**Status:** design
|
||||||
|
**Target release:** v0.2.0
|
||||||
|
**Scope:** extension only (`extension/src/setup/`, `extension/src/service-worker/`)
|
||||||
|
**Out of scope:** CLI `init` reconnect support, multi-vault per install, in-wizard "destroy and recreate" flow
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Today the setup wizard (`extension/src/setup/setup.ts`) has one flow: create a brand-new vault. Step 2 only checks that the configured remote is reachable; it does not detect whether that remote already contains a Relicario vault. Step 3's "create vault" then writes `.relicario/salt`, `.relicario/params.json`, `.relicario/devices.json`, and `manifest.enc` unconditionally — silently overwriting any existing vault on the remote.
|
||||||
|
|
||||||
|
**Observed failure:** uninstalling and reinstalling the extension while pointed at a populated test repo wipes the manifest with no warning. The user's test entries are gone.
|
||||||
|
|
||||||
|
The service worker already exposes `add_device`, `save_setup`, `unlock`, and `manifest_decrypt` machinery. The building blocks for "attach this device to an existing vault" exist; only the wizard UI is missing.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Provide a purely-GUI path to attach a new device to an existing vault, without touching the CLI.
|
||||||
|
2. Make destructive overwrite of an existing vault impossible from the wizard.
|
||||||
|
3. Verify the user's passphrase + reference image actually decrypt the existing vault before registering a new device key — no silently broken attachments.
|
||||||
|
4. Keep the existing "create new vault" flow working, with no behavioural regressions for greenfield setups.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Recovering from a partially-clobbered vault (out of scope; users with damaged remotes use git history).
|
||||||
|
- A "really nuke and recreate" escape hatch in the wizard. Users who genuinely want to start over delete the repo via the host's web UI.
|
||||||
|
- CLI parity. `relicario init` keeps its current "always fresh" semantics for now; a separate spec will cover CLI attach.
|
||||||
|
|
||||||
|
## UX flow
|
||||||
|
|
||||||
|
The wizard grows a leading **mode picker** (Step 0) and a parallel attach branch through Steps 3–5. Steps 1, 2, and 4 are shared between modes.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────── Step 0: mode ────────┐
|
||||||
|
│ create new | attach │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
▼
|
||||||
|
┌──── Step 1: host type ───────┐
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
▼
|
||||||
|
┌──── Step 2: host config ─────┐
|
||||||
|
│ URL + repo + token + test │
|
||||||
|
│ → vault-presence probe │
|
||||||
|
└──────┬─────────────┬─────────┘
|
||||||
|
new │ │ attach
|
||||||
|
▼ ▼
|
||||||
|
┌── Step 3a: carrier JPEG ─┐ ┌── Step 3b: reference JPEG ──┐
|
||||||
|
│ + passphrase + confirm │ │ + passphrase │
|
||||||
|
│ + zxcvbn ≥ 3 gate │ │ + verify-decrypt round-trip │
|
||||||
|
└────────────┬─────────────┘ └─────────────┬───────────────┘
|
||||||
|
▼ ▼
|
||||||
|
┌── Step 4: device name (shared) ──┐
|
||||||
|
└──────────────┬───────────────────┘
|
||||||
|
▼
|
||||||
|
┌── Step 5: register device + save config ──┐
|
||||||
|
│ new: + download reference.jpg │
|
||||||
|
│ attach: skip download │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The progress bar grows from 5 to 6 segments; Step 0 is the new leading segment.
|
||||||
|
|
||||||
|
### Step 0: mode picker
|
||||||
|
|
||||||
|
Two large buttons. No host configuration, no other inputs. Sets `state.mode` to `'new'` or `'attach'`. Helper copy under each:
|
||||||
|
|
||||||
|
- *create new vault* — "I'm setting up Relicario for the first time. This will create a fresh encrypted vault on a new or empty git repository."
|
||||||
|
- *attach this device* — "I already have a vault on another device. Connect this browser to it using my passphrase and reference image."
|
||||||
|
|
||||||
|
### Step 1: host type
|
||||||
|
|
||||||
|
Unchanged. Gitea/GitHub toggle + token-creation instructions. Shared by both modes.
|
||||||
|
|
||||||
|
### Step 2: host config + presence probe
|
||||||
|
|
||||||
|
Connection test is unchanged. **After** a successful test, run a vault-presence probe before allowing transition to Step 3:
|
||||||
|
|
||||||
|
1. `host.listDir('.relicario')` — collect filenames.
|
||||||
|
2. `host.listDir('')` — check root for `manifest.enc`.
|
||||||
|
3. Vault is "present" if any of `.relicario/salt`, `.relicario/params.json`, `manifest.enc` exist.
|
||||||
|
4. If vault is present, also fetch the most-recent commit metadata (`sha`, `author`, `date`) via the host's commits API for display. This is best-effort — failure to fetch metadata does not block the flow.
|
||||||
|
|
||||||
|
The probe result drives a banner under the connection-test row, with one of four states:
|
||||||
|
|
||||||
|
| mode | vault present | UI |
|
||||||
|
| --------- | ------------- | ------------------------------------------------------------------------------------ |
|
||||||
|
| `new` | no | green banner: "✓ repo is empty — ready to create a new vault." Next button enabled. |
|
||||||
|
| `new` | yes | red banner + warning card. Next disabled. Buttons: `[switch to attach]` `[back]`. |
|
||||||
|
| `attach` | yes | green banner + confirmation card with last-commit metadata. Next button enabled. |
|
||||||
|
| `attach` | no | red banner: "no vault found in this repo." Buttons: `[switch to new mode]` `[back]`. |
|
||||||
|
|
||||||
|
The "switch mode" buttons preserve all entered host config so the user does not retype anything.
|
||||||
|
|
||||||
|
**Warning card copy (mode=new, vault present):**
|
||||||
|
|
||||||
|
> ⚠ This repository already contains a Relicario vault.
|
||||||
|
> Last commit: `<sha7>` by `<author>` on `<date>`.
|
||||||
|
>
|
||||||
|
> Creating a new vault here would overwrite the existing one and **destroy all data inside**. To use this vault on this device, switch to *attach* mode instead.
|
||||||
|
>
|
||||||
|
> If you really mean to start over, delete the repository via your git host's web UI and come back here.
|
||||||
|
|
||||||
|
No "type the repo name to confirm" escape; deliberate friction routed through the host's own UI.
|
||||||
|
|
||||||
|
### Step 3a: create vault (new mode)
|
||||||
|
|
||||||
|
Largely unchanged from today's Step 3. Carrier JPEG + passphrase + confirm + zxcvbn ≥ 3 gate. On submit, embed image secret, derive key, encrypt empty manifest, push files. The presence probe already ran in Step 2, so the upload here is conditional on the repo still being empty *at probe time* — race-window narrowing belongs in the write layer (see "TOCTOU" below).
|
||||||
|
|
||||||
|
### Step 3b: attach (attach mode)
|
||||||
|
|
||||||
|
New step. Inputs:
|
||||||
|
|
||||||
|
- **Reference image (JPEG)** — file picker. Help text emphasises *reference, not carrier*: "upload the reference JPEG you saved when you first created this vault. Not the original photo — the one with the embedded secret."
|
||||||
|
- **Passphrase** — single field, no confirm (user is proving they know it, not setting a new one). Re-uses the same password input + show/hide eye toggle as Step 3a.
|
||||||
|
|
||||||
|
No zxcvbn meter on this step — the user does not get to set a passphrase, only enter the existing one.
|
||||||
|
|
||||||
|
On submit:
|
||||||
|
|
||||||
|
1. `GET .relicario/salt`, `.relicario/params.json`, `manifest.enc` from host.
|
||||||
|
2. `wasm.unlock(passphrase, referenceJpegBytes, salt, paramsJson)` → handle.
|
||||||
|
3. `wasm.manifest_decrypt(handle, manifestEnc)` → JSON.
|
||||||
|
4. On any throw: `wasm.lock(handle)` if a handle was created, set `state.error = "Could not decrypt vault — wrong passphrase or reference image."`, stay on form, no remote writes.
|
||||||
|
5. On success: stash decrypted manifest JSON and live handle in `state.verifiedHandle`. Continue to Step 4.
|
||||||
|
|
||||||
|
The verified handle is held only for the duration of the wizard. It is **not** pushed to the SW — after Step 5 finishes and the user opens the popup, they unlock again normally. The handle is locked at end-of-wizard regardless.
|
||||||
|
|
||||||
|
### Step 4: device name
|
||||||
|
|
||||||
|
Unchanged. Default name `${browser} on ${os}`. Shared by both modes.
|
||||||
|
|
||||||
|
### Step 5: register device + save config
|
||||||
|
|
||||||
|
Differences by mode:
|
||||||
|
|
||||||
|
| element | new mode | attach mode |
|
||||||
|
| --------------------------------- | -------- | ----------- |
|
||||||
|
| success header | "vault created" | "device attached" |
|
||||||
|
| reference.jpg download button | shown | hidden |
|
||||||
|
| save-config-to-extension button | shown | shown |
|
||||||
|
| add_device call | yes | yes |
|
||||||
|
|
||||||
|
Both modes call `add_device` via the SW with a freshly-generated keypair, write the private key to `chrome.storage.local`, and have the SW push the new pubkey into `.relicario/devices.json`.
|
||||||
|
|
||||||
|
**Implementation note for the plan:** verify the SW's `add_device` handler reads `devices.json` from the host, appends the new entry, and writes it back (read-modify-write). If it currently overwrites with a single-entry array, that is a pre-existing bug surfaced by attach mode and must be fixed as part of this work.
|
||||||
|
|
||||||
|
## State changes
|
||||||
|
|
||||||
|
`WizardState` gains:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
mode: 'new' | 'attach' | null; // null until Step 0 chosen
|
||||||
|
referenceImageBytesAttach: Uint8Array | null;
|
||||||
|
vaultProbe: {
|
||||||
|
exists: boolean;
|
||||||
|
lastCommit?: { sha: string; author: string; date: string };
|
||||||
|
} | null;
|
||||||
|
verifiedHandle: number | null; // WASM handle from Step 3b verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`carrierImageBytes` is kept distinct from `referenceImageBytesAttach` so the two paths cannot accidentally read each other's bytes.
|
||||||
|
|
||||||
|
`step` is renumbered to 0–5 (was 1–5). The progress bar grows to 6 segments.
|
||||||
|
|
||||||
|
## TOCTOU on the new-vault write path
|
||||||
|
|
||||||
|
The Step 2 probe is best-effort. A user could pass the probe with an empty repo, then between Step 2 and Step 3a's push, another client (or a previous wizard run) could initialise the same repo. The wizard's defence is the git-host write layer, not a re-probe:
|
||||||
|
|
||||||
|
- GitHub Contents API: `PUT /repos/{owner}/{repo}/contents/{path}` without a `sha` parameter creates only; if the file exists it returns 422.
|
||||||
|
- Gitea Contents API: same semantics — `POST` to create, `PUT` (with `sha`) to update.
|
||||||
|
|
||||||
|
Verify in the implementation plan that `host.writeFile` on the new path uses create-only semantics when called from Step 3a. If it currently does blind PUT-or-create, harden it for this code path. This is defence in depth — if it fails, the user gets a writeFile error mid-push and aborts, which is non-destructive (worst case: they leave a partial set of files behind, fixable by a second run that detects the partial vault and refuses).
|
||||||
|
|
||||||
|
The attach path does not have this concern — it only writes `devices.json`, and that is read-modify-write under the SW's existing handler.
|
||||||
|
|
||||||
|
## Error UX summary
|
||||||
|
|
||||||
|
| condition | behaviour |
|
||||||
|
| ----------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| connection test fails | red banner, stay on Step 2 |
|
||||||
|
| probe fails (network) | red banner "could not check repo state — retry"; do not proceed to Step 3 |
|
||||||
|
| mode=new, probe finds vault | warning card; only `[switch to attach]` or `[back]` advance |
|
||||||
|
| mode=attach, probe finds empty repo | warning card; only `[switch to new]` or `[back]` advance |
|
||||||
|
| mode=attach, decrypt fails in Step 3b | red banner "wrong passphrase or reference image"; stay on form; lock any partial handle |
|
||||||
|
| mode=new, conditional create rejects in Step 3a | red error referencing the file path that was rejected; advise re-running setup |
|
||||||
|
| `add_device` fails | red banner on Step 5; config save still succeeds; user can retry |
|
||||||
|
|
||||||
|
## Version + rollout
|
||||||
|
|
||||||
|
This is the first user-facing feature delivery since v0.1.0 and includes a fix for an unflagged data-loss bug. Bump all package versions to **0.2.0**:
|
||||||
|
|
||||||
|
- `crates/relicario-core/Cargo.toml`
|
||||||
|
- `crates/relicario-cli/Cargo.toml`
|
||||||
|
- `crates/relicario-wasm/Cargo.toml`
|
||||||
|
- `extension/manifest.json`
|
||||||
|
- `extension/package.json`
|
||||||
|
|
||||||
|
Tag `v0.2.0` after merge. Release notes should call out:
|
||||||
|
|
||||||
|
1. **Fix:** running setup against a remote that already contained a vault would silently overwrite it. Setup now refuses to overwrite and offers an attach path instead.
|
||||||
|
2. **Feature:** wizard now supports attaching a new device to an existing vault directly from the GUI (passphrase + reference image, no CLI).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit/integration coverage to add:
|
||||||
|
|
||||||
|
- `mode=new` happy path against an empty mock host — unchanged from existing tests.
|
||||||
|
- `mode=new` against a host that already returns `.relicario/salt` — wizard refuses, offers switch.
|
||||||
|
- `mode=attach` against an empty host — wizard refuses, offers switch.
|
||||||
|
- `mode=attach` happy path with valid passphrase + reference — `add_device` called, config saved.
|
||||||
|
- `mode=attach` with wrong passphrase — error displayed, no remote writes occur, no orphan device pubkey.
|
||||||
|
- `mode=attach` with mismatched reference image (right format, wrong embedded secret) — same as above.
|
||||||
|
- Mode-switch buttons preserve host URL / repo / token across the switch.
|
||||||
|
|
||||||
|
Manual verification:
|
||||||
|
|
||||||
|
- End-to-end on a real Gitea repo: create vault on workstation A, install fresh extension on workstation A, run attach wizard, verify popup unlocks and lists existing items unchanged.
|
||||||
|
|
||||||
|
## File touchpoints
|
||||||
|
|
||||||
|
- `extension/src/setup/setup.ts` — most of the work; new render functions, state additions, mode threading.
|
||||||
|
- `extension/src/setup/setup.html` — possibly minor adjustments for a 6-segment progress bar.
|
||||||
|
- `extension/src/service-worker/index.ts` — verify/adjust `add_device` handler if it does not read-modify-write `devices.json`.
|
||||||
|
- `extension/src/service-worker/git-host.ts` (or wherever `writeFile` lives) — verify create-only semantics on Step 3a's push.
|
||||||
|
- All five package version files (above).
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# Relicario import / export — design
|
||||||
|
|
||||||
|
Date: 2026-04-27
|
||||||
|
Status: design (not yet implemented)
|
||||||
|
Scope: backup / restore (round-trippable to Relicario itself) + LastPass CSV import. Migration **out** to other tools is explicitly out of scope.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Self-hosting a password vault without a backup story is unacceptable for production use. Today, a Relicario user has no way to:
|
||||||
|
1. **Snapshot** their vault for disaster recovery (git remote going away, repo corruption, account loss).
|
||||||
|
2. **Onboard** from an existing manager — there's no migration path for a user with credentials in another tool.
|
||||||
|
|
||||||
|
This design adds both, with parity across CLI and the fullscreen vault tab in the browser extension. The popup UI is unchanged (these are heavyweight workflows that don't fit the popup).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
The following choices were brainstormed and approved before this spec was written. They are stated as decisions, not options.
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| D1 | Two features, one spec: backup/restore round-trippable to Relicario, plus a LastPass CSV importer. Migration out is out of scope. |
|
||||||
|
| D2 | Backup file format: single-file `.relbak` container. Magic header + version + salt + nonce + AEAD-encrypted, zstd-compressed JSON envelope with base64'd binary blobs. |
|
||||||
|
| D3 | AEAD: XChaCha20-Poly1305 (same primitive used for vault items, but the backup format uses its own envelope with magic header + version byte; it does **not** reuse the `crypto.rs` `encrypt`/`decrypt` helpers, which assume the vault-master-key format). KDF: Argon2id with the same parameters as v1 of the live vault (m=64MiB, t=3, p=4) — but the params are tied to **backup format version**, not read from the vault's `params.json`. |
|
||||||
|
| D4 | Backup passphrase is independent of the vault passphrase. User picks one at export; user types it at restore. Reusing the vault passphrase is allowed but not auto-filled. |
|
||||||
|
| D5 | Reference image inclusion is optional. `--include-image` flag (CLI) / checkbox (UI). When included, the image is base64'd into the encrypted envelope — never in the clear inside the file. |
|
||||||
|
| D6 | Git history (`.git/`) is included **by default**. `--no-history` opt-out for users who want a smaller file at the cost of audit trail and remote URL. |
|
||||||
|
| D7 | Restore semantics: refuse if the target directory already contains a Relicario vault. Restore is a fresh round-trip operation, not a merge. |
|
||||||
|
| D8 | Backup passphrase strength: zxcvbn score ≥ 3, same gate as `init`. Backup is single-factor (one passphrase decrypts the container), so it must be at least as strong as a vault factor. |
|
||||||
|
| D9 | The user is responsible for deleting the backup file after restore is verified. The encryption protects it in transit / at rest while it exists; it is not a defense against forensic recovery of deleted copies. Documented in CLI help text and the extension UI. |
|
||||||
|
| D10 | LastPass import: parse the standard LastPass CSV (`url,username,password,totp,extra,name,grouping,fav`). Logins → `Login` items (with embedded TOTP if present); rows with `url == http://sn` → `SecureNote`; structured LastPass notes (cards, SSH keys, addresses) are **not** auto-parsed — they fall through as `SecureNote` with `extra` as the body. |
|
||||||
|
| D11 | Failed CSV rows are skipped with a warning; the import continues. CLI exits 0 if at least one item was imported. |
|
||||||
|
| D12 | Imported items always create new IDs, even if the `name` collides with an existing item. Relicario does not enforce title uniqueness; collisions are harmless. |
|
||||||
|
| D13 | An import is committed in **one** git commit covering all newly written items + the manifest. Mid-import crashes leave orphan item files (no manifest reference); safe to retry. |
|
||||||
|
| D14 | UI placement: CLI commands + fullscreen vault tab UI (`vault.html`) only. Popup is not touched. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three new modules. The bulk of the logic lives in `relicario-core` so CLI and extension share it.
|
||||||
|
|
||||||
|
### `relicario-core` (new code, ~250 LOC + tests)
|
||||||
|
|
||||||
|
- **`backup.rs`** — `pack_backup(...)` and `unpack_backup(...)`. Pure, bytes-in / bytes-out (no filesystem). Owns the JSON envelope schema, zstd compression, AEAD encryption, magic header, format-version handling.
|
||||||
|
- **`import_lastpass.rs`** — `parse_lastpass_csv(bytes) -> Result<(Vec<Item>, Vec<ImportWarning>)>`. Pure: takes CSV bytes, returns relicario `Item`s with freshly-minted IDs. Failed rows → `ImportWarning` entries alongside the items.
|
||||||
|
|
||||||
|
### `relicario-cli` (new commands)
|
||||||
|
|
||||||
|
- `relicario export <out.relbak> [--include-image] [--image <path>] [--no-history]`
|
||||||
|
- Reads vault root → packs → encrypts (prompts for backup passphrase, with confirmation + zxcvbn gate) → writes file with `atomic_write`.
|
||||||
|
- Does **not** require vault unlock. The backup container key is independent.
|
||||||
|
- `relicario restore <in.relbak> [<target_dir>]`
|
||||||
|
- `target_dir` defaults to current directory.
|
||||||
|
- Refuses if `target_dir/.relicario` exists.
|
||||||
|
- Prompts for backup passphrase → decrypts → unpacks → writes vault layout into target → if `.git/` was bundled, untar; otherwise `git init` + initial commit `"restore from backup <utc-timestamp>"`.
|
||||||
|
- User then unlocks normally with vault passphrase + reference image.
|
||||||
|
- `relicario import lastpass <csv>`
|
||||||
|
- Requires unlock.
|
||||||
|
- Parses CSV → encrypts each `Item` under master key → writes `items/<id>.enc` files → updates manifest in-memory → saves manifest last (the single commit point) → one git commit.
|
||||||
|
- Prints summary; exits 0 on partial success.
|
||||||
|
|
||||||
|
### `relicario-wasm` (new exports)
|
||||||
|
|
||||||
|
- `pack_backup_json(vault_state_json: &str, passphrase: &str) -> Vec<u8>` — thin wrapper around `core::pack_backup`. Takes a JSON description of vault state (the SW assembles it from chrome.storage / git fetches), returns the `.relbak` bytes.
|
||||||
|
- `unpack_backup_json(bytes: &[u8], passphrase: &str) -> String` — JSON-encoded inverse.
|
||||||
|
- `parse_lastpass_csv_json(csv_bytes: &[u8]) -> String` — JSON-encoded `(items, warnings)` tuple, ready for the SW to iterate via existing `add_item` calls.
|
||||||
|
|
||||||
|
### Extension (vault tab `vault.html` only — popup unchanged)
|
||||||
|
|
||||||
|
- New "Backup & restore" panel under settings (vault tab):
|
||||||
|
- **Export backup** — passphrase modal (with zxcvbn meter) → `chrome.downloads.download(blobUrl, "relicario-backup.relbak")`.
|
||||||
|
- **Restore from backup** — file picker → passphrase modal → confirms target is empty → restores via SW.
|
||||||
|
- New "Import" panel:
|
||||||
|
- File picker for LastPass CSV → SW parses → preview ("142 logins, 17 notes, 3 skipped — proceed?") → bulk-add via SW.
|
||||||
|
- Progress bar + inline warnings list.
|
||||||
|
|
||||||
|
## File format: `.relbak` v1
|
||||||
|
|
||||||
|
```
|
||||||
|
Offset Length Field
|
||||||
|
─────── ──────── ────────────────────────────────────────────────────────
|
||||||
|
0 4 Magic: ASCII "RBAK"
|
||||||
|
4 1 Format version: 0x01
|
||||||
|
5 32 Argon2id salt (random per export, 32 bytes)
|
||||||
|
37 24 XChaCha20-Poly1305 nonce (random per export, 24 bytes)
|
||||||
|
61 ... AEAD ciphertext + 16-byte Poly1305 tag
|
||||||
|
|
||||||
|
┌── after AEAD decryption ──┐
|
||||||
|
▼ ▼
|
||||||
|
zstd-compressed bytes
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
JSON document (UTF-8):
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"created_at": <unix-seconds>,
|
||||||
|
"vault": {
|
||||||
|
"salt": "<base64 of .relicario/salt>",
|
||||||
|
"params": { ... contents of .relicario/params.json verbatim ... },
|
||||||
|
"devices": [ ... contents of .relicario/devices.json verbatim ... ],
|
||||||
|
"manifest": "<base64 of manifest.enc>",
|
||||||
|
"settings": "<base64 of settings.enc>",
|
||||||
|
"items": { "<item-id-hex>": "<base64 of items/<id>.enc>", ... },
|
||||||
|
"attachments": { "<item-id>/<aid>": "<base64 of attachment blob>", ... },
|
||||||
|
"reference_jpg": "<base64>", // present iff --include-image
|
||||||
|
"git_archive": "<base64 of tarred .git/>" // present iff !--no-history
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
KDF parameters for v1 (hard-coded, NOT read from `params.json`):
|
||||||
|
- Algorithm: Argon2id
|
||||||
|
- Memory: 64 MiB
|
||||||
|
- Iterations: 3
|
||||||
|
- Parallelism: 4
|
||||||
|
- Output length: 32 bytes
|
||||||
|
|
||||||
|
Future format v2 may change these; v1 readers will see `version != 0x01` and produce a clear "newer version" error.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Read from disk (no vault unlock needed):
|
||||||
|
.relicario/salt, params.json, devices.json
|
||||||
|
manifest.enc, settings.enc
|
||||||
|
items/*.enc
|
||||||
|
attachments/<item>/*.enc
|
||||||
|
(optional) reference image -- via --include-image: from RELICARIO_IMAGE env, or --image <path>
|
||||||
|
(optional) tarred .git/ -- default-on; --no-history to skip
|
||||||
|
|
||||||
|
2. Build JSON envelope per the schema above. Binary fields → base64 (using
|
||||||
|
`data_encoding::BASE64` which already lives in the workspace).
|
||||||
|
|
||||||
|
3. zstd-compress the JSON document (level 3 — the speed/size sweet spot).
|
||||||
|
|
||||||
|
4. Prompt for backup passphrase (twice to confirm). Run zxcvbn gate; reject score < 3.
|
||||||
|
|
||||||
|
5. Generate fresh salt (32B) + nonce (24B) from `OsRng`.
|
||||||
|
|
||||||
|
6. Argon2id(passphrase, salt, v1-fixed params) → 32-byte key.
|
||||||
|
|
||||||
|
7. XChaCha20-Poly1305(key, nonce, compressed_bytes) → ciphertext.
|
||||||
|
|
||||||
|
8. atomic_write the file:
|
||||||
|
[magic "RBAK"][version 0x01][salt 32B][nonce 24B][ciphertext]
|
||||||
|
|
||||||
|
9. Print: "Wrote backup.relbak (N MiB). Delete after restore is verified."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
```
|
||||||
|
1. target_dir = arg or current dir. Refuse if target_dir/.relicario exists.
|
||||||
|
|
||||||
|
2. Read file. Verify magic (4 bytes "RBAK") and version (must be 0x01).
|
||||||
|
Read salt (32B), nonce (24B), ciphertext (rest).
|
||||||
|
|
||||||
|
3. Prompt for backup passphrase.
|
||||||
|
|
||||||
|
4. Argon2id(passphrase, salt, v1-fixed params) → 32B key.
|
||||||
|
|
||||||
|
5. XChaCha20-Poly1305 decrypt → zstd decompress → parse JSON.
|
||||||
|
Bad passphrase / tampered file → AEAD authentication failure;
|
||||||
|
surface as "wrong backup passphrase, or the file is corrupt"
|
||||||
|
(deliberately ambiguous, like vault unlock).
|
||||||
|
|
||||||
|
6. Validate envelope.schema_version == 1.
|
||||||
|
|
||||||
|
7. Write into target_dir:
|
||||||
|
.relicario/salt
|
||||||
|
.relicario/params.json
|
||||||
|
.relicario/devices.json
|
||||||
|
manifest.enc
|
||||||
|
settings.enc
|
||||||
|
items/<id>.enc for each
|
||||||
|
attachments/<item>/<aid>.enc for each
|
||||||
|
(if present) reference.jpg in target_dir root
|
||||||
|
(if present) untar git_archive into target_dir/.git
|
||||||
|
|
||||||
|
8. If git_archive was NOT in the envelope:
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git -c hooks=disabled commit -m "restore from backup <iso8601-utc>"
|
||||||
|
|
||||||
|
9. Print: "Restored vault to <target>. Unlock with your passphrase + reference image."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import LastPass
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Vault.unlock_interactive() — need master key to encrypt new items.
|
||||||
|
|
||||||
|
2. Read CSV bytes from filesystem (CLI) or File API (extension).
|
||||||
|
|
||||||
|
3. core::parse_lastpass_csv(bytes) → (Vec<Item>, Vec<ImportWarning>)
|
||||||
|
Each Item already has:
|
||||||
|
- fresh ItemId (random 8-char hex per existing convention)
|
||||||
|
- title from `name`
|
||||||
|
- group from `grouping` (None if empty)
|
||||||
|
- favorite from `fav == "1"`
|
||||||
|
- core mapped per the table below
|
||||||
|
|
||||||
|
4. Encrypt each item under master key. Write items/<id>.enc.
|
||||||
|
Update manifest in-memory: manifest.upsert(&item).
|
||||||
|
|
||||||
|
5. Save manifest.enc (atomic_write — this is the single commit point).
|
||||||
|
|
||||||
|
6. ONE git commit covering all new items/*.enc + manifest.enc:
|
||||||
|
"import: <N> items from LastPass (<csv-filename>)"
|
||||||
|
|
||||||
|
7. Print summary:
|
||||||
|
"Imported <N>, skipped <K> (see warnings above)"
|
||||||
|
Exit 0 if N > 0, else 1.
|
||||||
|
```
|
||||||
|
|
||||||
|
## LastPass field mapping
|
||||||
|
|
||||||
|
| LastPass column | Relicario destination | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `Item.title` | Required; row skipped with warning if missing |
|
||||||
|
| `grouping` | `Item.group` | `None` if empty |
|
||||||
|
| `fav` | `Item.favorite` | `"1"` → `true`, anything else → `false` |
|
||||||
|
| `url` | `LoginCore.url` (parsed) | The literal value `"http://sn"` is LastPass's secure-note marker — when seen, the **row** is mapped to `SecureNote` (not Login) and the URL field is not stored. For ordinary login rows, an invalid URL is imported as `url = None` with a warning. |
|
||||||
|
| `username` | `LoginCore.username` | `None` if empty |
|
||||||
|
| `password` | `LoginCore.password` | Required for `Login` rows; missing → row skipped |
|
||||||
|
| `totp` | `LoginCore.totp` | If non-empty: base32-decode; build `TotpConfig { secret, algorithm: Sha1, digits: 6, period_seconds: 30, kind: Totp }`. Bad base32 → warning, login imported without TOTP. |
|
||||||
|
| `extra` (when `url != http://sn`) | `Item.notes` | Multi-line preserved |
|
||||||
|
| `extra` (when `url == http://sn`) | `SecureNoteCore.body` | Verbatim, even when LastPass packed structured data into it |
|
||||||
|
|
||||||
|
Items where every required field for a `Login` is present and `url != http://sn` map to `ItemCore::Login`. Otherwise, if `url == http://sn`, map to `ItemCore::SecureNote`. Otherwise, the row is skipped with a warning explaining why.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
| Error | Detection | User-facing message | Recovery |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Not in a vault | `vault_dir()` fails | `"no .relicario/ found"` | `cd` to vault root |
|
||||||
|
| Missing reference image | `fs::read` of `--image` path fails | `"cannot read reference image: <path>"` | Fix path or drop `--include-image` |
|
||||||
|
| Backup passphrase too weak | zxcvbn score < 3 | `"backup passphrase too weak (score N): <feedback>"` | Choose a longer/more-entropic phrase |
|
||||||
|
| Disk full / permission denied | `atomic_write` returns `io::Error` | propagated `io::Error` with file path | Free space / fix permissions |
|
||||||
|
|
||||||
|
Atomicity: output uses the existing `atomic_write` helper (write `.tmp` → rename). Partial output files are never visible.
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
| Error | Detection | User-facing message | Recovery |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Bad magic | First 4 bytes ≠ `"RBAK"` | `"not a Relicario backup file"` | Verify file |
|
||||||
|
| Unsupported version | Version byte > current (1) | `"backup created by a newer Relicario; upgrade required"` | Update binary |
|
||||||
|
| Wrong backup passphrase | AEAD authentication fails | `"wrong backup passphrase, or the file is corrupt"` (deliberately ambiguous) | Retry |
|
||||||
|
| Target dir already has a vault | `target/.relicario/` exists | `"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory"` | Choose empty dir |
|
||||||
|
| Schema mismatch | envelope.schema_version != current | `"backup is schema v<N>; this Relicario reads v<M>"` | Use matching binary |
|
||||||
|
| Mid-restore crash | (no detection) | — | User deletes target dir, retries |
|
||||||
|
|
||||||
|
Atomicity: best-effort. If interrupted mid-write, target dir has partial files — user cleans up and retries. Documented limitation. Restore is rare enough that engineering atomic-rename of multiple files is not worth the complexity.
|
||||||
|
|
||||||
|
### Import LastPass
|
||||||
|
|
||||||
|
| Error | Detection | User-facing message | Recovery |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CSV header missing/malformed | First-line parse fails | `"unrecognized CSV header — expected LastPass export format"` | Re-export from LastPass |
|
||||||
|
| Row missing required field | Per-row validation | Logged warning: `"row N: missing 'name' — skipped"` | Row skipped; no manual recovery |
|
||||||
|
| Bad base32 TOTP | base32 decode fails | Logged warning: `"row N (<title>): invalid TOTP secret — login imported without TOTP"` | Login imported sans TOTP |
|
||||||
|
| Vault locked | Pre-flight unlock | `"unlock failed"` | Retry passphrase |
|
||||||
|
| Mid-import crash | (no detection) | — | Items written before crash are orphan files (no manifest reference); safe to retry — will create new IDs, possibly duplicating |
|
||||||
|
|
||||||
|
Atomicity: manifest is the single source of truth and is written **last**, with `atomic_write`. Item files written before the manifest are referenced only after the manifest commits. Orphans don't pollute the vault — they're invisible until the user runs a future "vault gc" sweep (out of scope here).
|
||||||
|
|
||||||
|
### Progress feedback
|
||||||
|
|
||||||
|
- **CLI**: stderr line every 50 items: `"[150/1247] importing..."`. Final summary on success: `"Imported 1244, skipped 3 (see warnings above)"`. Non-zero exit only if zero items imported.
|
||||||
|
- **Extension (vault tab)**: progress bar with same denominator. Inline warnings list. Final toast.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Core tests (`crates/relicario-core/tests/`)
|
||||||
|
|
||||||
|
Pure logic, no IO. New files:
|
||||||
|
|
||||||
|
- `backup.rs`
|
||||||
|
- Pack → unpack round-trip preserves bytes for empty vault, vault-with-attachments, vault-with-git-history.
|
||||||
|
- Wrong passphrase → AEAD auth error (use `RelicarioError::AuthenticationFailed` or equivalent).
|
||||||
|
- Tampered ciphertext / magic / version → format error variants.
|
||||||
|
- `--include-image` round-trips the JPEG; absence honored.
|
||||||
|
- `--no-history` produces a strict subset (no `git_archive` in envelope).
|
||||||
|
- `import_lastpass.rs`
|
||||||
|
- Standard login row → `Login` with all fields populated.
|
||||||
|
- `url == http://sn` → `SecureNote`.
|
||||||
|
- TOTP base32 → embedded `TotpConfig`.
|
||||||
|
- Bad base32 → warning, login imported without TOTP.
|
||||||
|
- Missing `name` / `password` → row skipped + warning.
|
||||||
|
- Quoted-comma, multi-line `extra`, unicode all parse cleanly.
|
||||||
|
- `grouping`, `fav`, `name` pass through to `Item`.
|
||||||
|
|
||||||
|
Tests use fast Argon2id params (m=256, t=1, p=1) per the existing convention.
|
||||||
|
|
||||||
|
### CLI integration tests (`crates/relicario-cli/tests/`)
|
||||||
|
|
||||||
|
End-to-end with the existing `TestVault` harness. New files:
|
||||||
|
|
||||||
|
- `backup.rs`
|
||||||
|
- `init` → add 3 items → `export` → fresh-dir `restore` → `unlock` → `list` shows the same 3 items.
|
||||||
|
- Restore refuses non-empty target with the documented error.
|
||||||
|
- Wrong backup passphrase fails on restore.
|
||||||
|
- `--include-image` carries the reference image; restored vault unlocks without separate `--image` arg.
|
||||||
|
- `--no-history` produces a smaller file; restored vault has only the `"restore from backup"` commit.
|
||||||
|
- `import_lastpass.rs`
|
||||||
|
- Fixture CSV → `import lastpass` → `list` shows the imported items.
|
||||||
|
- Single git commit covers all imports (verify via `git log --oneline`).
|
||||||
|
- Skipped rows produce warnings on stderr; CLI exits 0 if any item imported.
|
||||||
|
- Title collision with existing item → both kept (decision D12).
|
||||||
|
|
||||||
|
### Extension tests (vitest, mocked WASM/SW)
|
||||||
|
|
||||||
|
- `extension/src/vault/__tests__/backup-panel.test.ts` — renders Export / Restore / Import buttons; click → right SW message.
|
||||||
|
- Extend `extension/src/service-worker/router/__tests__/router.test.ts` with `export_backup`, `restore_backup`, `import_lastpass` cases — sender = vault tab, popup is rejected.
|
||||||
|
- `extension/src/service-worker/__tests__/backup.test.ts` — SW handler calls `pack_backup_json`, returns Blob bytes for download.
|
||||||
|
- Mocked WASM returns deterministic envelopes; assertions on payload structure.
|
||||||
|
|
||||||
|
### Fixtures
|
||||||
|
|
||||||
|
- `crates/relicario-cli/tests/fixtures/lastpass-sample.csv` — ~15 synthesized rows, no real credentials. Coverage:
|
||||||
|
- Standard login
|
||||||
|
- Login with TOTP
|
||||||
|
- Login with embedded URL TOTP that decodes correctly
|
||||||
|
- Login with bad base32 TOTP (warning case)
|
||||||
|
- SecureNote (`url == http://sn`)
|
||||||
|
- Grouped item
|
||||||
|
- Favorite item
|
||||||
|
- Malformed row (missing `name`)
|
||||||
|
- Unicode title (covers UTF-8 handling)
|
||||||
|
- Multi-line `extra` (quoted, embedded newlines)
|
||||||
|
- Backup fixtures are generated per-test via `setup()`; not committed.
|
||||||
|
|
||||||
|
## Out of scope / future work
|
||||||
|
|
||||||
|
- **Migration out** to other tools' formats (1Password 1pux, Bitwarden JSON, KeePass kdbx, generic CSV). Could be added later if users ask.
|
||||||
|
- **Other importers**: 1Password, Bitwarden, Chrome, Firefox. LastPass-only for now; plan is to add one importer per concrete user need rather than speculating.
|
||||||
|
- **Vault GC sweep**: orphan-file detection (items on disk without a manifest entry, attachments without an item). Useful after interrupted imports, but a separate feature.
|
||||||
|
- **Merge restore**: restoring a backup INTO an existing vault (rather than refusing). Conceptually overlaps with the future "sharing" feature; deferring decision.
|
||||||
|
- **Backup encryption with the vault factor**: requiring passphrase + reference image to unlock the backup, mirroring the live vault's 2FA. Conceptually possible but adds complexity, was rejected in brainstorming in favor of the standalone backup-passphrase model.
|
||||||
|
- **Cloud-backed automatic backups**: scheduled backups to Dropbox/S3/etc. Out of scope; users can wrap `relicario export` in cron.
|
||||||
|
|
||||||
|
## Appendix A: estimated effort
|
||||||
|
|
||||||
|
| Component | LOC est. | Days |
|
||||||
|
|---|---|---|
|
||||||
|
| `core::backup` (pack/unpack + format) | ~150 | 1 |
|
||||||
|
| `core::import_lastpass` (parser + mapping) | ~120 | 0.5 |
|
||||||
|
| Core tests | ~250 | 0.5 |
|
||||||
|
| CLI commands (export, restore, import lastpass) | ~200 | 0.5 |
|
||||||
|
| CLI integration tests + fixtures | ~200 | 0.5 |
|
||||||
|
| WASM bindings (3 new exports) | ~50 | 0.25 |
|
||||||
|
| SW handlers (export, restore, import) | ~150 | 0.5 |
|
||||||
|
| Vault tab UI (Backup & restore panel + Import panel) | ~400 | 1 |
|
||||||
|
| Vitest tests | ~200 | 0.5 |
|
||||||
|
| Documentation (CHANGELOG, CLI help, UI copy) | — | 0.25 |
|
||||||
|
|
||||||
|
**Total: ~5.5 dev-days end-to-end** for full CLI + extension parity. The estimate is a guideline, not a commitment.
|
||||||
|
|
||||||
|
## Appendix B: risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| LastPass changes their CSV format mid-stream | low | medium | Pin to today's column order; document expected header; surface a clear error on header mismatch so users don't silently get garbage |
|
||||||
|
| Backup files end up large (with `.git/`) | medium | low | `--no-history` opt-out; document trade-off in CLI help |
|
||||||
|
| User loses backup passphrase | medium | catastrophic | Document explicitly in CLI help and UI: "the backup passphrase cannot be recovered. If you lose it, the backup is unreadable." |
|
||||||
|
| zstd / Argon2id WASM bundle size | low | low | Both are already in our dep tree (Argon2id) or small (zstd ~100KB). Verify total wasm bundle stays under 4 MiB. |
|
||||||
|
| Cross-platform path / line-ending issues in `.git/` tar | low | medium | Use `tar` crate's portable defaults; test round-trip on linux + mac in CI if available |
|
||||||
154
docs/superpowers/specs/2026-04-27-relicario-vault-tab-design.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Vault Tab UI + Session Timeout — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-27
|
||||||
|
**Scope:** New `vault.html` full-tab UI, shared session timeout, popup↔vault navigation
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Chrome extension popups close when focus leaves them (e.g., file picker dialogs). The popup is also too cramped for complex operations like editing identity/card items, managing attachments, or bulk vault operations. Currently we work around this with `popOutToTab()` which opens `popup.html` in a tab — a hack that reuses popup-sized UI in a full window.
|
||||||
|
|
||||||
|
Additionally, there's no session timeout — users must re-enter their passphrase every time they interact with the extension.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Two entry points, one shared core
|
||||||
|
|
||||||
|
- **`popup.html`** — quick access: search, copy, autofill, add login/secure_note (without attachments)
|
||||||
|
- **`vault.html`** — full "desktop" UI in a browser tab: sidebar + detail pane, handles everything including attachments, bulk operations, trash, devices, settings, field history
|
||||||
|
|
||||||
|
Both talk to the same service worker, share the same WASM session handle and unlock state.
|
||||||
|
|
||||||
|
### vault.html layout
|
||||||
|
|
||||||
|
Sidebar + detail pane, similar to 1Password's desktop app:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ 🔒 Relicario [lock] [settings] │
|
||||||
|
├────────────────┬─────────────────────────────────┤
|
||||||
|
│ [search...] │ │
|
||||||
|
│ │ (detail view for selected │
|
||||||
|
│ ── logins ── │ item, or form when │
|
||||||
|
│ GitHub 🔑 │ adding/editing) │
|
||||||
|
│ AWS 🔑 │ │
|
||||||
|
│ │ │
|
||||||
|
│ ── notes ── │ │
|
||||||
|
│ Recovery 📝 │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
├────────────────┤ │
|
||||||
|
│ 🗑 trash │ │
|
||||||
|
│ 📱 devices │ │
|
||||||
|
│ ⚙ settings │ │
|
||||||
|
└────────────────┴─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Left sidebar (~240px):** vault name/lock status at top, search input, item list grouped by type, nav links at bottom (trash, devices, settings)
|
||||||
|
- **Right pane:** detail view for selected item, or add/edit form. Empty state when nothing selected.
|
||||||
|
- URL hash tracks current selection (`#item/abc123`, `#add/login`, `#trash`, etc.) for browser back/forward
|
||||||
|
|
||||||
|
### Session timeout
|
||||||
|
|
||||||
|
Lives in the **service worker**, not in any UI. Shared across popup and vault tab.
|
||||||
|
|
||||||
|
**Timer logic** — new `session-timer.ts` module alongside existing `session.ts`:
|
||||||
|
- Holds a `setTimeout` ID, reads config from `chrome.storage.local`
|
||||||
|
- Resets on every message routed through the SW (any popup or vault tab interaction)
|
||||||
|
- When it fires: calls `clearCurrent()` to zero the WASM handle, then broadcasts `{ type: 'session_expired' }` via `chrome.runtime.sendMessage`
|
||||||
|
- Both popup and vault tab listen for this broadcast and show the lock screen
|
||||||
|
|
||||||
|
**Config shape** in `chrome.storage.local`:
|
||||||
|
```json
|
||||||
|
{ "session_timeout": { "mode": "inactivity", "minutes": 15 } }
|
||||||
|
```
|
||||||
|
or:
|
||||||
|
```json
|
||||||
|
{ "session_timeout": { "mode": "every_time" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Default: `{ mode: 'inactivity', minutes: 15 }`. This is a **per-device setting** (stored in `chrome.storage.local`, not in the encrypted vault) since different devices have different risk profiles.
|
||||||
|
|
||||||
|
**UI for timeout config:** In a "device settings" section, a simple toggle:
|
||||||
|
- "Lock after inactivity" with a minutes dropdown (5, 15, 30, 60)
|
||||||
|
- "Lock every time" (current behavior)
|
||||||
|
|
||||||
|
Changing the setting sends an `update_session_config` message to the SW which immediately applies the new timer.
|
||||||
|
|
||||||
|
### Navigation between popup and vault
|
||||||
|
|
||||||
|
**Popup → vault:**
|
||||||
|
- "Open vault" link on the lock screen and item list toolbar
|
||||||
|
- `Shift+F` keydown listener in popup — opens/focuses the vault tab
|
||||||
|
- When navigating from popup with context (e.g., viewing an item), pass item ID via URL: `vault.html#item/abc123`
|
||||||
|
- `popOutToTab()` now redirects to `vault.html` instead of `popup.html` for types that need it
|
||||||
|
|
||||||
|
**Global shortcut:**
|
||||||
|
- `chrome.commands` manifest entry (default unbound, user configures in `chrome://extensions/shortcuts`)
|
||||||
|
- SW listener opens or focuses existing vault tab
|
||||||
|
|
||||||
|
**Vault → popup:**
|
||||||
|
- Not needed — vault tab is the superset
|
||||||
|
|
||||||
|
### Shared components
|
||||||
|
|
||||||
|
Form renderers (login, secure-note, identity, card, key, totp, document), field helpers, attachments disclosure, generator panel are currently in `popup/components/`. These get moved to `shared/components/` so both entry points can import them.
|
||||||
|
|
||||||
|
The popup wrappers conditionally hide attachments (via `isInTab()`); the vault versions always show everything.
|
||||||
|
|
||||||
|
### Keyboard shortcuts
|
||||||
|
|
||||||
|
| Key | Context | Action |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `/` | Popup list, vault sidebar | Focus search |
|
||||||
|
| `+` | Popup list, vault sidebar | New item |
|
||||||
|
| `↑↓` | Popup list, vault sidebar | Navigate items |
|
||||||
|
| `Enter` | Popup list, vault sidebar | Open selected item |
|
||||||
|
| `Escape` | Popup | Close popup |
|
||||||
|
| `Escape` | Vault form/detail | Back to list |
|
||||||
|
| `Shift+F` | Popup | Open/focus vault tab |
|
||||||
|
| Global | Anywhere in Chrome | Open/focus vault tab (user-configured) |
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
```
|
||||||
|
extension/
|
||||||
|
├── src/
|
||||||
|
│ ├── vault/
|
||||||
|
│ │ ├── vault.ts # Entry point, state management, hash routing
|
||||||
|
│ │ ├── vault-shell.ts # Layout container, sidebar/pane split
|
||||||
|
│ │ ├── vault-sidebar.ts # Search, grouped item list, nav links
|
||||||
|
│ │ └── vault-pane.ts # Detail/form/settings renderer
|
||||||
|
│ ├── shared/
|
||||||
|
│ │ └── components/ # Moved from popup/components/
|
||||||
|
│ │ ├── types/ # login.ts, secure-note.ts, etc.
|
||||||
|
│ │ ├── fields.ts
|
||||||
|
│ │ ├── attachments-disclosure.ts
|
||||||
|
│ │ └── generator-panel.ts
|
||||||
|
│ ├── service-worker/
|
||||||
|
│ │ └── session-timer.ts # Inactivity timeout logic
|
||||||
|
│ └── popup/
|
||||||
|
│ └── components/ # Thin wrappers that import from shared/
|
||||||
|
├── vault.html # New entry point
|
||||||
|
└── vault.css # Vault-specific layout styles (imports shared)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What stays in popup
|
||||||
|
|
||||||
|
The popup keeps its stacked-view navigation and compact layout. It imports form/detail components from `shared/` but wraps them in popup-specific chrome (back buttons, condensed headers). Login and secure_note forms render inline in the popup (without attachments); all other types redirect to `vault.html`.
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
New message types:
|
||||||
|
- `update_session_config` — popup/vault → SW, updates timeout settings
|
||||||
|
- `get_session_config` — popup/vault → SW, reads current timeout settings
|
||||||
|
|
||||||
|
New broadcast:
|
||||||
|
- `session_expired` — SW → all extension views, triggers lock screen
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Grouping/tagging/export features (future work, mentioned as eventual goal)
|
||||||
|
- Mobile-style responsive layout for vault tab
|
||||||
|
- Theme customization
|
||||||
|
- Multi-vault support
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
# Relicario fullscreen UX redesign
|
||||||
|
|
||||||
|
**Date:** 2026-04-30
|
||||||
|
**Status:** Spec, awaiting review
|
||||||
|
**Surface:** Browser extension fullscreen vault UI (`extension/src/vault/`)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Make the fullscreen vault tab (`vault.html`) feel like a first-class app, not a popup form stretched across a wide monitor.
|
||||||
|
- Add structural affordances (keyboard nav, command palette, multi-select) that the popup cannot fit and that match the project's monospace/terminal aesthetic.
|
||||||
|
- Improve form-level affordances (smart inputs) in a way the popup can also adopt where space allows.
|
||||||
|
- Establish a consistent visual language — typography, glyphs, focus states, button conventions — shared between popup and fullscreen.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Sidebar/empty-state rework (deliberately out of scope; current sidebar layout stays as-is).
|
||||||
|
- Mobile responsive design (fullscreen is desktop-only; popup handles narrow widths).
|
||||||
|
- New item types, schema changes, or sync-protocol changes.
|
||||||
|
- Theme system / light mode (single dark theme stays).
|
||||||
|
|
||||||
|
## Scope summary
|
||||||
|
|
||||||
|
| Theme | Where it applies |
|
||||||
|
|---|---|
|
||||||
|
| **A.** Two-column form layout, sticky save bar | Fullscreen only |
|
||||||
|
| **B.** Visual polish: glyphs, focus rings, required pill, "esc to cancel" subtitle | Both (popup adopts what fits) |
|
||||||
|
| **C.** Smart inputs (8 affordances) | Both — same code path in `popup/components/types/login.ts` |
|
||||||
|
| **E.** Keyboard nav, ⌘K palette, three-pane shell, multi-select, drag-drop attach, unsaved-changes guard, recent items | Fullscreen only |
|
||||||
|
| **Glyph button convention** | Both (icons-only, native tooltips) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Component map (after redesign)
|
||||||
|
|
||||||
|
```
|
||||||
|
extension/src/vault/
|
||||||
|
├── vault.ts # entry point — restructured for 3-pane shell
|
||||||
|
├── vault.html # split panes: nav | list | detail
|
||||||
|
├── vault.css # restyled — see "visual language" below
|
||||||
|
├── shell/
|
||||||
|
│ ├── three-pane.ts # NEW — pane sizing, divider drag
|
||||||
|
│ ├── keymap.ts # NEW — global keyboard handler
|
||||||
|
│ ├── command-palette.ts # NEW — ⌘K overlay
|
||||||
|
│ └── unsaved-guard.ts # NEW — beforeunload + in-app intercept
|
||||||
|
├── selection.ts # NEW — multi-select state
|
||||||
|
└── components/ # existing — backup-panel, import-panel
|
||||||
|
|
||||||
|
extension/src/popup/components/types/
|
||||||
|
├── login.ts # restructured form, 8 smart-input affordances
|
||||||
|
├── secure-note.ts # adopts shared visual language
|
||||||
|
├── identity.ts # ditto (later phase)
|
||||||
|
├── card.ts # ditto (later phase)
|
||||||
|
├── key.ts # ditto (later phase)
|
||||||
|
├── totp.ts # ditto (later phase)
|
||||||
|
└── document.ts # ditto (later phase)
|
||||||
|
|
||||||
|
extension/src/shared/
|
||||||
|
├── glyphs.ts # NEW — icon glyph constants & button helper
|
||||||
|
├── shortcuts.ts # NEW — keymap registry consumed by vault
|
||||||
|
└── form-affordances/ # NEW — reusable smart-input mixins
|
||||||
|
├── url-affordances.ts # fill-from-tab, hostname chip
|
||||||
|
├── group-autocomplete.ts # datalist
|
||||||
|
├── password-tools.ts # reveal toggle, strength bar
|
||||||
|
└── totp-tools.ts # live preview, QR decode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
No changes to the message-bus contract. New SW handlers needed:
|
||||||
|
|
||||||
|
- `get_active_tab_url` — popup-only message; SW reads `chrome.tabs.query({active:true, lastFocusedWindow:true})`, returns `{ url, title }`. Used by URL fill-from-tab affordance.
|
||||||
|
- `list_groups` — popup-only; reads manifest, returns deduplicated set of all group strings (for datalist autocomplete).
|
||||||
|
- `list_recently_viewed` — popup-only; returns last N item IDs from a per-device LRU stored in `chrome.storage.local`.
|
||||||
|
|
||||||
|
Existing handlers (`rate_passphrase`, `get_totp`, `add_item`, etc.) are reused as-is.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- **`jsqr`** — `~50KB` minified. QR-image → otpauth-URI decoder for TOTP-from-QR. Loaded lazily (only when the user clicks the `◫` button).
|
||||||
|
- No other new runtime deps. `zxcvbn` already integrated via `rate_passphrase`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual language
|
||||||
|
|
||||||
|
The single source of truth for shared style is `extension/src/shared/glyphs.ts` (constants) and `vault.css` / `popup.css` (CSS tokens).
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Body: `ui-monospace, "JetBrains Mono", "SF Mono", monospace` (already present).
|
||||||
|
- Numerals: `font-variant-numeric: tabular-nums` on TOTP code, countdowns, item counts.
|
||||||
|
- Labels: lowercase, weight 400, color `var(--text-muted)`.
|
||||||
|
- Section headers (form sub-sections): uppercase, letter-spacing 1px, weight 500, with a 1px bottom border.
|
||||||
|
|
||||||
|
### Color tokens (additive — no existing colors removed)
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--accent: #d49b3a; /* amber, brand */
|
||||||
|
--accent-soft: rgba(212, 155, 58, 0.18);
|
||||||
|
--focus-ring: 0 0 0 2px rgba(212, 155, 58, 0.35);
|
||||||
|
--bg-input: #0e1620;
|
||||||
|
--bg-pane: #1a2230;
|
||||||
|
--border-subtle: #2a3848;
|
||||||
|
--text: #cdd6e0;
|
||||||
|
--text-muted: #8b97a8;
|
||||||
|
--text-dim: #6b7888;
|
||||||
|
--danger: #c75a4f;
|
||||||
|
--success: #6cb37a;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Glyph convention
|
||||||
|
|
||||||
|
All action glyphs are unicode (no emoji), monochrome, with `title=` tooltips. Defined as constants in `shared/glyphs.ts`:
|
||||||
|
|
||||||
|
| Glyph | Constant | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `⊙` / `⊘` | `GLYPH_REVEAL` / `GLYPH_HIDE` | Password reveal toggle |
|
||||||
|
| `↻` | `GLYPH_GENERATE` | Password / passphrase generate |
|
||||||
|
| `⤓` | `GLYPH_FILL_FROM_TAB` | Fill URL from active tab |
|
||||||
|
| `◫` | `GLYPH_QR` | Paste/upload QR image |
|
||||||
|
| `≡` | `GLYPH_MONO` | Toggle notes monospace |
|
||||||
|
| `▦` | `GLYPH_TRASH` | Trash nav (replaces 🗑) |
|
||||||
|
| `⌬` | `GLYPH_DEVICES` | Devices nav (replaces 📺) |
|
||||||
|
| `⚙` | `GLYPH_SETTINGS` | Settings nav (kept) |
|
||||||
|
| `⏻` | `GLYPH_LOCK` | Lock nav (replaces 🔒) |
|
||||||
|
| `⌘ K` | (literal) | Command palette label |
|
||||||
|
|
||||||
|
Buttons use a shared `.glyph-btn` class: 28px min-width, monospace, neutral background, hover lift.
|
||||||
|
|
||||||
|
### Focus state
|
||||||
|
|
||||||
|
Single token `--focus-ring` applied to all focusable form elements via `:focus-visible`. Browser default outline is suppressed. Combined with a 1px amber border on the focused input.
|
||||||
|
|
||||||
|
### Required-field pill
|
||||||
|
|
||||||
|
Replaces the trailing `*` marker. A `<span class="req-pill">required</span>` after the label text:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.req-pill {
|
||||||
|
display: inline-block; font-size: 9px; padding: 1px 5px;
|
||||||
|
background: var(--accent-soft); color: var(--accent);
|
||||||
|
border-radius: 2px; margin-left: 6px; vertical-align: middle;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Form layout (fullscreen only)
|
||||||
|
|
||||||
|
The fullscreen `vault.html` form pane gets a two-column layout for login items. Other types stay single-column for now.
|
||||||
|
|
||||||
|
### Layout rules
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ ◀ new login ⌘+S to save │
|
||||||
|
│ unsaved · esc to cancel │
|
||||||
|
├──────────────────────────┬─────────────────────────────────┤
|
||||||
|
│ IDENTITY │ CREDENTIALS │
|
||||||
|
│ ┌──────────────────────┐ │ ┌─────────────────────────────┐ │
|
||||||
|
│ │ title [required] │ │ │ username │ │
|
||||||
|
│ │ url + ⤓ │ │ │ password ⊙ ↻ │ │
|
||||||
|
│ │ group (autocomplete) │ │ │ strength: ████░ │ │
|
||||||
|
│ └──────────────────────┘ │ │ totp secret ◫ │ │
|
||||||
|
│ │ │ live: 492 837 · 23s │ │
|
||||||
|
│ │ └─────────────────────────────┘ │
|
||||||
|
├──────────────────────────┴─────────────────────────────────┤
|
||||||
|
│ NOTES │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ▾ custom sections & fields ▸ attachments │
|
||||||
|
├────────────────────────────────────────────────────────────┤
|
||||||
|
│ STICKY SAVE BAR [cancel] [save] │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pane content max-width: `960px`, centered horizontally in the pane.
|
||||||
|
- Two columns: equal width, 24px gap. Stack to single column under 720px viewport (degrades gracefully for narrow windows).
|
||||||
|
- Notes / custom sections / attachments are full-width below the columns.
|
||||||
|
- **Sticky save bar:** position `sticky` at the bottom of the form pane, with a fade gradient above so content scrolls under it. Always reachable, even on long forms.
|
||||||
|
|
||||||
|
### Header treatment
|
||||||
|
|
||||||
|
- Heading "new login" / "edit login" left-aligned.
|
||||||
|
- Subtitle below: "unsaved · esc to cancel" (when dirty) or "no changes" (when pristine).
|
||||||
|
- Right side: keyboard hint "⌘+S to save" (visual only — not a button).
|
||||||
|
- The popout-to-tab `⤴` button is **removed** from the fullscreen form (it's a no-op in this context). It stays in the popup form.
|
||||||
|
|
||||||
|
### Fields per item type (column assignment)
|
||||||
|
|
||||||
|
Only `login` is two-column. Other types (`secure_note`, `identity`, `card`, `key`, `totp`, `document`) remain single-column with the polish/visual-language updates applied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Visual polish (both surfaces, popup adopts what fits)
|
||||||
|
|
||||||
|
Six tweaks, applied via `vault.css` / `popup.css`:
|
||||||
|
|
||||||
|
1. **Popout button:** removed from fullscreen forms. Stays in popup forms.
|
||||||
|
2. **Sidebar glyphs:** emoji → unicode constants from `shared/glyphs.ts`.
|
||||||
|
3. **Required pill:** `<span class="req-pill">required</span>` replaces trailing `*`.
|
||||||
|
4. **Focus ring:** `--focus-ring` token on `:focus-visible`.
|
||||||
|
5. **Form header subtitle:** "unsaved · esc to cancel" / "no changes" status line.
|
||||||
|
6. **Rhythm:** input padding raised from 5px → 6px, line-height 1.4 → 1.5, label margin tweaks for breathing room.
|
||||||
|
|
||||||
|
The popup adopts (3), (4), (5 — minus "esc to cancel" since popup escape closes the popup). Popup keeps (2) sidebar glyphs. Layout (sticky bar / two-column) does not apply to popup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Smart inputs (both surfaces)
|
||||||
|
|
||||||
|
Each affordance lives in `shared/form-affordances/` so the popup and fullscreen forms call the same module.
|
||||||
|
|
||||||
|
### C1. Fill URL from current tab
|
||||||
|
|
||||||
|
- New SW message: `get_active_tab_url` → `{ url: string, title: string } | null`. Uses `chrome.tabs.query({active:true, lastFocusedWindow:true})`, filters out `chrome://` / extension URLs.
|
||||||
|
- Glyph button `⤓` next to the URL input. Click → fetch → set URL field; if title field is empty, set title too.
|
||||||
|
- No-op (button disabled) if no usable active tab (e.g., user opened vault.html and no other tab).
|
||||||
|
|
||||||
|
### C2. Hostname chip next to URL
|
||||||
|
|
||||||
|
- Live: parse the URL with `URL` constructor on each input event (debounced 200ms).
|
||||||
|
- If it parses, show a chip with the first letter of the hostname on a colored background + the bare hostname underneath the input.
|
||||||
|
- No network fetch. No favicon download. Pure visual confirmation.
|
||||||
|
|
||||||
|
### C3. Group autocomplete (datalist)
|
||||||
|
|
||||||
|
- New SW message: `list_groups` → `{ groups: string[] }`. Reads `state.manifest.items`, collects unique non-empty `group` values, sorts.
|
||||||
|
- Form's group input gets `<datalist>` attribute. Browser handles dropdown UI.
|
||||||
|
- One round-trip on form open; cached for the form's lifetime.
|
||||||
|
|
||||||
|
### C4. Password reveal toggle
|
||||||
|
|
||||||
|
- Glyph button `⊙` (hidden) / `⊘` (revealed) next to password input.
|
||||||
|
- Click toggles `input.type` between `password` ↔ `text` and swaps glyph.
|
||||||
|
- Resets to `password` when the form is unmounted (paranoia: don't leak revealed-state across navigation).
|
||||||
|
|
||||||
|
### C5. Inline strength bar (zxcvbn)
|
||||||
|
|
||||||
|
- Below password input: 5-segment bar + label "strength: weak / fair / good / strong · ~10ⁿ guesses".
|
||||||
|
- Drives off existing `rate_passphrase` SW message. Debounced 150ms (already done in `setup-helpers.ts`; reuse the helper).
|
||||||
|
- Color: red (score 1) → amber (score 2-3) → green (score 4) per existing palette.
|
||||||
|
|
||||||
|
### C6. TOTP live code preview
|
||||||
|
|
||||||
|
- Below the totp-secret input: when the field contains a valid base32 string (length ≥ 16, charset `A-Z2-7`), show "492 837 · 23s" in a dashed-bordered preview box.
|
||||||
|
- Drives off a new SW message: `preview_totp` → `{ code, expires_at }`. Or reuse `get_totp` with a transient secret. **Preferred:** new `preview_totp_from_secret { secret_b32 }` so we don't pollute the get_totp path with unsaved data.
|
||||||
|
- Updates every second (interval ticker, torn down on unmount).
|
||||||
|
|
||||||
|
### C7. TOTP from QR image (paste / upload)
|
||||||
|
|
||||||
|
- Glyph button `◫` opens a small inline panel with three sources:
|
||||||
|
1. **Paste:** listen for `paste` event on the panel; extract image from clipboard.
|
||||||
|
2. **Upload:** `<input type=file accept=image/*>`.
|
||||||
|
3. **Drop:** drag image into the panel area.
|
||||||
|
- Lazy-load `jsqr` (`import('jsqr')` only when panel opens). Decode → if URI starts with `otpauth://`, parse the `secret` query param → fill the totp-secret field.
|
||||||
|
- On failure: inline error "no QR found" / "not a TOTP URI".
|
||||||
|
|
||||||
|
### C8. Notes monospace toggle
|
||||||
|
|
||||||
|
- Small glyph button `≡` near the notes label. Toggles `font-family` between body and `ui-monospace` for the textarea.
|
||||||
|
- Persisted per-item in `chrome.storage.local` keyed by item ID (purely a display preference, not encrypted state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Power-user features (fullscreen only)
|
||||||
|
|
||||||
|
### E1. Three-pane shell
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────┬──────────────────┬──────────────────────────────────┐
|
||||||
|
│ NAV │ LIST + SEARCH │ DETAIL / FORM │
|
||||||
|
│ │ │ │
|
||||||
|
│ + │ /search │ ... │
|
||||||
|
│ ▦ │ ─────────────── │ │
|
||||||
|
│ ⌬ │ GitHub │ │
|
||||||
|
│ ⚙ │ GitLab │ │
|
||||||
|
│ ⏻ │ Reddit │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
└─────┴──────────────────┴──────────────────────────────────┘
|
||||||
|
60px 320px (resizable) flex: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- Leftmost pane (60px): icon-only nav (`+ new`, `▦ trash`, `⌬ devices`, `⚙ settings`, `⏻ lock`). Hover tooltips show labels.
|
||||||
|
- Middle pane (320px default, resizable via drag divider, persisted in `chrome.storage.local`): search input + item list.
|
||||||
|
- Right pane (fills remaining width): current view (detail, form, settings, devices, etc.).
|
||||||
|
- Resizable divider between middle and right panes; min 240px / max 60% of viewport.
|
||||||
|
|
||||||
|
Migration from current 2-pane: extract the bottom nav buttons from the sidebar into the new leftmost pane. Existing list rendering moves to the middle pane unchanged.
|
||||||
|
|
||||||
|
### E2. Keyboard navigation
|
||||||
|
|
||||||
|
A new `extension/src/vault/shell/keymap.ts` registers a single global keydown handler. Shortcuts only fire when no input/textarea is focused (or `/` always focuses search):
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `j` / `↓` | Next item in list |
|
||||||
|
| `k` / `↑` | Previous item in list |
|
||||||
|
| `Enter` | Open detail of selected item |
|
||||||
|
| `e` | Edit currently-open item |
|
||||||
|
| `/` | Focus search |
|
||||||
|
| `Esc` | Close detail / cancel form / clear search |
|
||||||
|
| `⌘N` / `Ctrl+N` | New item (open type-selection) |
|
||||||
|
| `⌘L` / `Ctrl+L` | Lock vault |
|
||||||
|
| `⌘S` / `Ctrl+S` | Save current form (when editing/adding) |
|
||||||
|
| `⌘K` / `Ctrl+K` | Open command palette |
|
||||||
|
| `gg` | Jump to top of list |
|
||||||
|
| `G` | Jump to bottom of list |
|
||||||
|
| `x` | Toggle multi-select on focused list row |
|
||||||
|
|
||||||
|
Implementation: small dispatch table; consumers register handlers tagged by view (`list`, `detail`, `form`); the keymap module routes based on current view + focus state.
|
||||||
|
|
||||||
|
### E3. Command palette (⌘K)
|
||||||
|
|
||||||
|
- Modal overlay, centered, ~520px wide.
|
||||||
|
- Input at top; fuzzy-matches against all decrypted item titles + URLs + groups + a handful of static actions ("new login", "lock", "open settings", etc.).
|
||||||
|
- Up/down arrow + enter to select; ⌘K or Esc to close.
|
||||||
|
- Implementation: simple substring + token matching (no third-party fuzzy lib). Renders top 8 results.
|
||||||
|
- Actions executed via the existing `navigate()` host method.
|
||||||
|
|
||||||
|
### E4. Unsaved-changes guard
|
||||||
|
|
||||||
|
- New `extension/src/vault/shell/unsaved-guard.ts` exports `setDirty(dirty: boolean)` / `isDirty()`.
|
||||||
|
- Form components call `setDirty(true)` on any input change, `setDirty(false)` on save/cancel/initial render.
|
||||||
|
- Browser tab close: `window.addEventListener('beforeunload', e => isDirty() && e.preventDefault())`.
|
||||||
|
- In-app navigation: `navigate()` host method checks `isDirty()`, shows a toast confirmation ("Discard changes?" — keep editing / discard).
|
||||||
|
|
||||||
|
### E5. Multi-select bulk operations
|
||||||
|
|
||||||
|
- New `extension/src/vault/selection.ts` holds a `Set<ItemId>` of selected items.
|
||||||
|
- List rows render a checkbox (only visible on hover, or always when ≥1 item selected).
|
||||||
|
- Shift-click a row toggles selection. `x` keymap toggles focused row.
|
||||||
|
- Footer action bar appears when selection is non-empty: "N selected" + buttons (move to group, trash).
|
||||||
|
- Bulk operations call existing per-item handlers in a loop, with a single manifest write at the end. SW handler: `bulk_trash_items` and `bulk_move_to_group` to keep the round-trips down.
|
||||||
|
|
||||||
|
### E6. Drag-drop attachments anywhere on form
|
||||||
|
|
||||||
|
- The whole form pane becomes a drop target when a drag enters with `dataTransfer.types.includes('Files')`.
|
||||||
|
- Overlay shows "⤓ drop to attach" with the per-attachment size cap.
|
||||||
|
- Drop → forwards files to existing `attachments-disclosure.ts` upload pipeline, which already handles encryption and SW round-trip.
|
||||||
|
|
||||||
|
### E7. Recent items in sidebar
|
||||||
|
|
||||||
|
- New SW message: `record_view_item { id }` (called when detail pane renders an item) and `list_recently_viewed { limit }` (called by sidebar on render).
|
||||||
|
- Backed by an LRU in `chrome.storage.local` (per-device, NOT in the encrypted vault — leaks no data because only IDs are stored, and IDs are random opaque strings).
|
||||||
|
- Sidebar shows a "recent" mini-section above the main list (last 3 items, collapsible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parity matrix (popup vs fullscreen)
|
||||||
|
|
||||||
|
| Feature | Popup | Fullscreen |
|
||||||
|
|---|---|---|
|
||||||
|
| Two-column form layout | — | ✓ (login only) |
|
||||||
|
| Sticky save bar | — | ✓ |
|
||||||
|
| Header subtitle | "esc to close" | "esc to cancel · ⌘+S to save" |
|
||||||
|
| Popout-to-tab button | ✓ | — |
|
||||||
|
| Sidebar glyphs | ✓ | ✓ |
|
||||||
|
| Required pill | ✓ | ✓ |
|
||||||
|
| Focus ring | ✓ | ✓ |
|
||||||
|
| Smart inputs (C1–C8) | ✓ | ✓ |
|
||||||
|
| Three-pane shell | — | ✓ |
|
||||||
|
| Keyboard nav | — | ✓ |
|
||||||
|
| Command palette | — | ✓ |
|
||||||
|
| Unsaved-changes guard | — | ✓ (popup auto-closes on Esc; loss is implicit) |
|
||||||
|
| Multi-select bulk ops | — | ✓ |
|
||||||
|
| Drag-drop attachments | partial (existing) | ✓ (whole form pane) |
|
||||||
|
| Recent items section | — | ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation phases (suggested split)
|
||||||
|
|
||||||
|
The work is large enough to want phased landings. Each phase is independently shippable.
|
||||||
|
|
||||||
|
### Phase 1: Visual foundation
|
||||||
|
- `shared/glyphs.ts`, color tokens, focus ring, required pill, sidebar glyph migration, popout button removal in fullscreen.
|
||||||
|
- Touches both popup and fullscreen CSS.
|
||||||
|
- Smallest, lowest-risk; sets the visual baseline for everything else.
|
||||||
|
|
||||||
|
### Phase 2: Form layout + smart inputs
|
||||||
|
- `shared/form-affordances/` modules.
|
||||||
|
- Two-column login form in fullscreen, sticky save bar, header subtitle.
|
||||||
|
- All 8 smart inputs wired in `login.ts` (touches popup too).
|
||||||
|
- New SW messages: `get_active_tab_url`, `list_groups`, `preview_totp_from_secret`.
|
||||||
|
- Lazy-load `jsqr` for QR decode.
|
||||||
|
|
||||||
|
### Phase 3: Three-pane shell + keyboard nav
|
||||||
|
- `vault/shell/three-pane.ts`, `keymap.ts`, `unsaved-guard.ts`.
|
||||||
|
- Restructure `vault.html` and `vault.ts` for the new shell.
|
||||||
|
- All shortcuts wired.
|
||||||
|
|
||||||
|
### Phase 4: Command palette + multi-select + drag-drop + recent items
|
||||||
|
- `vault/shell/command-palette.ts`, `vault/selection.ts`.
|
||||||
|
- Drag-drop attach overlay.
|
||||||
|
- `record_view_item` / `list_recently_viewed` SW handlers.
|
||||||
|
- Bulk SW handlers: `bulk_trash_items`, `bulk_move_to_group`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing approach
|
||||||
|
|
||||||
|
Existing `vitest` setup with `happy-dom` is sufficient for the new components. Per-phase test additions:
|
||||||
|
|
||||||
|
- **Phase 1:** Snapshot test for `shared/glyphs.ts` constants. Visual regression: manual.
|
||||||
|
- **Phase 2:** Per-affordance unit tests in `shared/form-affordances/__tests__/`. Each tests the parse/format logic and DOM mutation in isolation. Form integration test that mounts the login form and exercises all 8 affordances.
|
||||||
|
- **Phase 3:** Keymap dispatch table tests (verify each key resolves to the right handler given current view+focus). Three-pane shell test: mount, simulate divider drag, verify width persistence.
|
||||||
|
- **Phase 4:** Command palette fuzzy-match tests (input → expected result ordering). Multi-select selection-state tests. Bulk-op handler tests (router.test.ts pattern).
|
||||||
|
|
||||||
|
No new e2e infrastructure; manual QA pass per phase with the rebuilt extension loaded in Chrome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI parity
|
||||||
|
|
||||||
|
The user's design philosophy: every user-facing capability lands on **both** CLI and extension together. Most of this spec is UI-shaped (form layout, three-pane shell, command palette, drag-drop) and has no CLI counterpart by nature. The remaining items where this design introduces a genuine parity gap:
|
||||||
|
|
||||||
|
| Feature | CLI counterpart | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| **C3** group autocomplete | `relicario` clap completion script with dynamic group enumeration for `--group <TAB>` | **In scope** — bundle with C3 |
|
||||||
|
| **C5** password strength bar | New `relicario rate <passphrase>` subcommand printing zxcvbn score + guess count | **In scope** — bundle with C5 |
|
||||||
|
| **C7** TOTP from QR | New flag `relicario add login --totp-qr <path-to-image>` (and `edit`) | **In scope** — bundle with C7 |
|
||||||
|
| **E5** multi-select bulk ops | `relicario rm <q1> <q2> ...` (vararg) and bulk move/group counterparts | **In scope** — bundle with E5 |
|
||||||
|
| **E7** recent items | `relicario list --recent <N>` flag (LRU stored in vault dir) | **In scope** — bundle with E7 |
|
||||||
|
|
||||||
|
Items with parity already satisfied:
|
||||||
|
|
||||||
|
- **C4 password reveal** ↔ existing `get --show` flag
|
||||||
|
- **C6 TOTP code preview** ↔ existing `get` (`get` always shows the code; live preview is UI-only nicety)
|
||||||
|
- **C8 notes monospace** ↔ CLI prints monospace by default
|
||||||
|
- **E2 keyboard nav** ↔ CLI is keyboard-native
|
||||||
|
- **E3 command palette** ↔ CLI subcommand discovery via `--help`
|
||||||
|
- **E4 unsaved guard** ↔ CLI is single-action per invocation; nothing to lose
|
||||||
|
- **E6 drag-drop attach** ↔ existing `attach <id> <file>`
|
||||||
|
|
||||||
|
The CLI counterparts above land in the same phase as their extension counterpart (e.g., `rate` subcommand ships in Phase 2 with C5, not as a follow-up). The implementation plan must pair them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope / deferred
|
||||||
|
|
||||||
|
- Sidebar empty-state ("no items" CTA, etc.) — explicitly skipped per brainstorm.
|
||||||
|
- Light theme / theme picker.
|
||||||
|
- Mobile / narrow fullscreen layouts (under 720px).
|
||||||
|
- Vim-style chord shortcuts beyond `gg` / `G`.
|
||||||
|
- Pinned/favorite items as a sidebar section (favorite field already exists; not surfacing it differently right now).
|
||||||
|
- Auto-save drafts (unsaved guard catches the common case; full draft persistence is a separate effort).
|
||||||
|
- Form-level diff view ("you changed 3 fields") — would be nice but not asked for.
|
||||||
145
docs/superpowers/specs/2026-05-01-password-coloring-design.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Password display character-class coloring
|
||||||
|
|
||||||
|
**Status:** design
|
||||||
|
**Target release:** v0.4.0 (or earlier — bundles cleanly with active fullscreen UX work)
|
||||||
|
**Scope:** extension only — popup (`extension/src/popup/`), fullscreen vault (`extension/src/vault/`), settings UI for color customization
|
||||||
|
**Out of scope:** CLI parity (TTY color escapes for revealed passwords are a separate problem; defer until there's user demand), per-item color overrides, theming the rest of the extension, coloring inside copy-to-clipboard payloads (clipboard always carries plaintext).
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
When a password is revealed in 1Password's UI, characters are colored by class to make passwords easier to scan, dictate, compare, and transcribe:
|
||||||
|
|
||||||
|
- digits — distinct color (1Password uses blue)
|
||||||
|
- symbols — distinct color (1Password uses red)
|
||||||
|
- letters — default text color
|
||||||
|
|
||||||
|
Concrete benefits:
|
||||||
|
|
||||||
|
- reading a generated password aloud without confusing similar-shaped characters
|
||||||
|
- spotting transcription errors when typing a password into a non-relicario field
|
||||||
|
- visually parsing dense symbol runs in long generated passwords
|
||||||
|
|
||||||
|
relicario's password reveal currently renders a flat-colored monospace string. Today this happens in:
|
||||||
|
|
||||||
|
- `extension/src/popup/components/field-history.ts` (history viewer's revealed cells)
|
||||||
|
- whatever vault item-detail view renders the current password value (popup + fullscreen `vault/`)
|
||||||
|
- the generator preview as a candidate password is rolled
|
||||||
|
|
||||||
|
This spec adds a single shared utility that colorizes those renders, plus a settings surface for users to pick custom digit/symbol colors.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Color-code revealed password characters by class — digit, symbol, letter — across all extension surfaces that display revealed passwords.
|
||||||
|
2. Default scheme: digits blue, symbols red. Letters use existing text color.
|
||||||
|
3. User-customizable digit and symbol colors via a settings page, persisted in `chrome.storage.sync` so preferences follow the user across browser profiles.
|
||||||
|
4. Single source of truth: one `colorizePassword()` helper used everywhere, so adding a new password-display surface in the future inherits the coloring automatically.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Coloring confusable-character pairs (`l`/`1`/`I`, `0`/`O`) with a third color. Possible future work; out of scope here.
|
||||||
|
- CLI parity. The CLI currently doesn't render revealed passwords inline (it shells the value to clipboard or stdout); ANSI coloring would be a separate decision.
|
||||||
|
- Coloring OTP/2FA codes. They are all digits and would gain nothing.
|
||||||
|
- Affecting the copy-to-clipboard pathway. Clipboard payloads remain plaintext.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Character classification
|
||||||
|
|
||||||
|
Three classes via simple regex over Unicode codepoints:
|
||||||
|
|
||||||
|
- `digit` — `/^\d$/` (matches Unicode `Nd` category via JS `\d`)
|
||||||
|
- `letter` — `/^[\p{L}]$/u`
|
||||||
|
- `symbol` — anything else (punctuation, symbols, whitespace)
|
||||||
|
|
||||||
|
Each codepoint is classified once; the helper batches consecutive same-class codepoints into a single `<span>` to keep the DOM small for long passwords.
|
||||||
|
|
||||||
|
### `colorizePassword` utility
|
||||||
|
|
||||||
|
New file: `extension/src/popup/components/password-coloring.ts` (or `extension/src/shared/` if a shared module already exists; reviewer of the plan to decide).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function colorizePassword(password: string): DocumentFragment {
|
||||||
|
// Returns a fragment of <span class="pwd-digit|pwd-symbol|pwd-letter">…</span>
|
||||||
|
// runs covering the full input. Empty input → empty fragment.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pure function, no DOM mutation outside the returned fragment. Easy to unit-test with `JSDOM`.
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
Defined in the existing extension stylesheet(s) — popup and vault both import a shared rule set:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--relicario-pwd-digit-color: #2563eb; /* blue-600 */
|
||||||
|
--relicario-pwd-symbol-color: #dc2626; /* red-600 */
|
||||||
|
}
|
||||||
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.pwd-letter { color: inherit; }
|
||||||
|
```
|
||||||
|
|
||||||
|
User customization is implemented by a tiny `applyColorScheme()` boot step that reads `chrome.storage.sync.password_display_scheme` at popup/vault startup and writes the values onto `document.documentElement.style` as inline `--relicario-pwd-*-color` overrides. No CSS-in-JS, no runtime style injection for each render — set once, read by every subsequent `colorizePassword()` output.
|
||||||
|
|
||||||
|
### Storage shape
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// chrome.storage.sync key: "password_display_scheme"
|
||||||
|
{
|
||||||
|
"digit_color": "#2563eb", // hex string, validated on read
|
||||||
|
"symbol_color": "#dc2626"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing key or invalid values → fall back to defaults, no error surface. Sync (not local) so preferences propagate across the user's browser profiles. No security implication — purely cosmetic.
|
||||||
|
|
||||||
|
### Settings UI
|
||||||
|
|
||||||
|
Adds a **Display** section to the existing extension settings page (the page reachable from the gear icon — exact route to be confirmed by the plan; the existing settings surface is in `extension/src/popup/components/settings.ts` or equivalent).
|
||||||
|
|
||||||
|
- Two color pickers labelled "Digit color" and "Symbol color".
|
||||||
|
- Live preview swatch beneath the pickers showing a sample password (`Abc123!@#xyz`) rendered with the candidate colors. Updates as the user changes pickers.
|
||||||
|
- "Reset to defaults" button — clears the storage key, swatch reverts to defaults.
|
||||||
|
- Inline accessibility hint: if the chosen color falls below WCAG AA contrast (≥ 4.5 : 1) against the surface's background color, show a subtle "may be hard to read on this background" warning under the picker. Non-blocking — the user can still save.
|
||||||
|
|
||||||
|
### Surfaces to update
|
||||||
|
|
||||||
|
Each touchpoint just swaps a textContent assignment for `colorizePassword(value)` and appends the returned fragment.
|
||||||
|
|
||||||
|
- Vault item detail (popup view) — wherever the password field renders its revealed value.
|
||||||
|
- Vault item detail (fullscreen view) — same logic for `extension/src/vault/`.
|
||||||
|
- Field-history viewer (`field-history.ts:72`) — the `<div class="history-entry__value revealed">` content swap.
|
||||||
|
- Generator preview — the live candidate-password preview as the generator rolls.
|
||||||
|
|
||||||
|
The fullscreen UX redesign (Phase 1, per `docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md` and the recent commit `9ed7e7c`) is currently in flight. This spec coordinates with that work: if any reveal-rendering code is being rewritten as part of Phase 1, the rewrite should call `colorizePassword()` instead of plain text-content. The plan author should confirm with the user (they're doing the web-UX work themselves, per their own message) whether to land this concurrently or stage it as a follow-up.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Additive only. No data migration. Users without a stored scheme get defaults. Existing tests for password-display behavior may need updating to expect the span structure instead of plain text — those updates are part of this work.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit (Vitest, matching existing extension test conventions):
|
||||||
|
|
||||||
|
1. `colorizePassword("aB3$xY")` returns spans in correct classification order: `pwd-letter "aB"`, `pwd-digit "3"`, `pwd-symbol "$"`, `pwd-letter "xY"`.
|
||||||
|
2. Empty string returns empty fragment, zero children.
|
||||||
|
3. All-letters / all-digits / all-symbols inputs produce a single span of the appropriate class.
|
||||||
|
4. Unicode letters (e.g., `áñü`) classify as `pwd-letter` via `\p{L}`.
|
||||||
|
5. Whitespace classifies as `pwd-symbol` (verified, not accidental).
|
||||||
|
6. Snapshot test on a representative password: `aB3$xY7&_!` → expected fragment structure.
|
||||||
|
|
||||||
|
Integration:
|
||||||
|
|
||||||
|
7. `applyColorScheme()` reads `chrome.storage.sync` and sets CSS variables on `document.documentElement`. Mock `chrome.storage.sync.get`, assert resulting inline style.
|
||||||
|
8. Settings UI: changing a picker writes to storage; reset clears storage; both reflected in subsequent renders via the storage event listener.
|
||||||
|
|
||||||
|
Visual regression:
|
||||||
|
|
||||||
|
9. Manual: open vault, reveal a password with mixed character classes, confirm coloring matches expectation in popup and fullscreen views.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether to colorize concealed (non-password) fields — the existing `concealed` field type also reveals on click. Default position: yes, apply the same coloring; concealed fields are typically API tokens/keys with mixed character classes, so they benefit equally. Confirm with user during implementation.
|
||||||
|
- Whether to add a third color for "look-alike" characters (defer; small follow-up if/when the user asks).
|
||||||
|
- Exact route for the Display section in settings (popup-settings vs vault-settings vs both). Plan to resolve based on existing settings architecture.
|
||||||
241
docs/superpowers/specs/2026-05-01-recovery-qr-design.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Recovery QR + passphrase entropy floor — disaster recovery for lost reference image
|
||||||
|
|
||||||
|
**Status:** design
|
||||||
|
**Target release:** v0.4.0 (post-v0.3.0 train)
|
||||||
|
**Scope:** `relicario-core` (new `recovery_qr` module + extracted `normalize_passphrase`), `relicario-cli` (new `recovery-qr` subcommand group), `relicario-wasm` (bindings), extension (display/print route + vault-tab button + init-wizard zxcvbn gate)
|
||||||
|
**Out of scope:** passphrase-loss recovery (deliberate non-goal), online or server-mediated recovery, multi-device key sharing, threshold schemes, device onboarding "magic link" (separate effort), in-extension webcam QR scanning (a future feature; v1 unlocks via paste).
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Relicario's two-factor model derives `master_key = Argon2id(len-prefixed(passphrase) || image_secret, salt, params)` (`crates/relicario-core/src/crypto.rs:207`). Lose either factor and the vault is unrecoverable. The reference image is the more loseable factor — it lives outside the user's head, often as a "dead drop" on social media or a personal site, and a single platform takedown or accidental deletion permanently bricks the vault.
|
||||||
|
|
||||||
|
The original design spec already sketched a post-V1 recovery path (`docs/superpowers/specs/2026-04-11-relicario-design.md:342-349`): a small encrypted file containing only `image_secret`, locked under the passphrase via a separate Argon2id derivation, stored offline. This spec finalizes that sketch with three refinements landed during brainstorming:
|
||||||
|
|
||||||
|
1. The artifact is a **QR code displayed on screen** (primary) or printed (secondary) — never written to disk. The user snaps the displayed QR with a phone or prints a hard copy. "Memory-only" is enforced architecturally: no API path produces a file.
|
||||||
|
2. **Domain separation** in the recovery KDF input prevents collision with the main `derive_master_key` output namespace under adversarial inputs.
|
||||||
|
3. A **passphrase entropy floor** is enforced at vault init. Recovery-QR security is exactly `passphrase_strength × Argon2id_cost`; without an entropy floor at init, a user can configure their vault into a state where the recovery QR is brute-forceable on commodity hardware.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Provide an offline, paper-or-photo fallback that recovers `image_secret` when the reference image is lost but the passphrase is known.
|
||||||
|
2. Make it impossible — by API shape, not convention — to (a) write the recovery payload to disk, (b) generate it with weak Argon2id parameters, or (c) compute it without NFC-normalizing the passphrase identically to the main KDF.
|
||||||
|
3. Enforce a passphrase entropy floor at vault init so the recovery-QR security guarantee is not silently undermined.
|
||||||
|
4. Surface the feature in CLI, extension vault tab, and the new-vault wizard with parity (see `feedback_cli_extension_parity` in user memory).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Recovering from a forgotten passphrase. Forgotten passphrases remain unrecoverable; this is the deliberate stance for a self-hosted password manager with no recovery server.
|
||||||
|
- Re-introducing TOTP, online recovery, or any third factor. The brainstorm explicitly settled on 1-of-2 with a paper substitute for the second factor.
|
||||||
|
- Retroactively forcing existing vaults whose passphrases are below the new entropy floor to rotate. Existing vaults are grandfathered with a non-blocking warning.
|
||||||
|
- Vault format change. The recovery QR is a derived artifact; the vault on disk is unchanged.
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
| Attacker capability | What this protects | What it does not protect |
|
||||||
|
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Photographs the displayed QR or steals the printed paper | Recovery payload alone is useless: it's `image_secret` encrypted under Argon2id-of-passphrase. Attacker must additionally brute-force the passphrase, gated by Argon2id cost (m=64 MiB, t=3, p=4). With a passphrase at the enforced entropy floor (zxcvbn ≥ 3, ≈ 10¹⁰ guesses), brute-force is infeasible on commodity hardware. | A weak passphrase (zxcvbn < 3) below the floor — but the floor is enforced at init, so this only applies to grandfathered vaults that pre-date this feature. |
|
||||||
|
| Captures recovery payload + already knows passphrase | Nothing — equivalent to the existing "compromised reference image + passphrase" failure mode that the vault has always accepted as the universal worst case. | Same. |
|
||||||
|
| Reads files written to disk by relicario | Recovery payload is never written to disk by any code path. No file artifact exists to read. | OS print spooler may briefly cache a print job (Windows: `C:\Windows\System32\spool\PRINTERS\`). Print is the secondary path; users with concerns use the display path. |
|
||||||
|
| MitM on git transport | Recovery payload never traverses git or any network — it lives only in user-rendered output. | N/A |
|
||||||
|
| Crafts adversarial inputs to confuse vault KDF and recovery KDF outputs | Domain separation tag `b"relicario-recovery-v1\0"` prefixes the recovery KDF input, ensuring no input can produce identical Argon2id outputs across the two namespaces. | N/A |
|
||||||
|
|
||||||
|
## Cryptographic design
|
||||||
|
|
||||||
|
### Recovery KDF input
|
||||||
|
|
||||||
|
```text
|
||||||
|
recovery_kdf_input =
|
||||||
|
b"relicario-recovery-v1\0" // 22-byte domain separator
|
||||||
|
|| u64_be(len(nfc(passphrase))) // 8 bytes
|
||||||
|
|| nfc(passphrase) // variable
|
||||||
|
```
|
||||||
|
|
||||||
|
Fed to Argon2id with `RecoveryKdfParams::production()` and a fresh 32-byte salt generated at recovery-QR creation time (separate from the vault salt). Output is a 32-byte `wrap_key`.
|
||||||
|
|
||||||
|
Argon2id is a PRF, so distinct inputs yield uncorrelated outputs with negligible collision probability. The domain separator's role is to make inputs structurally distinguishable: the vault KDF input begins with `u64_be(passphrase_len)`, whose first 6+ bytes are zero for any realistic passphrase length (< 2⁴⁸ bytes), while the recovery KDF input begins with the literal ASCII `relicario-recovery-v1\0` — non-zero from byte 0. This is robust against any adversarially crafted passphrase value because the structural prefix difference is independent of passphrase content.
|
||||||
|
|
||||||
|
### Wrap
|
||||||
|
|
||||||
|
```text
|
||||||
|
nonce = OsRng(24)
|
||||||
|
ciphertext = XChaCha20-Poly1305(wrap_key, nonce, image_secret) // 32 + 16 = 48 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Same AEAD primitive as the vault. Reuses `crypto::encrypt`/`crypto::decrypt` after the wrap key is derived.
|
||||||
|
|
||||||
|
### QR payload (binary)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[magic "RREC" 4 bytes ] // matches the "RBAK" pattern from backup.rs:29
|
||||||
|
[version 0x01 1 byte ]
|
||||||
|
[salt 32 bytes ]
|
||||||
|
[nonce 24 bytes ]
|
||||||
|
[ciphertext 48 bytes ] // 32 plaintext + 16 Poly1305 tag
|
||||||
|
// ───────────
|
||||||
|
// 109 bytes total
|
||||||
|
```
|
||||||
|
|
||||||
|
Salt is included so recovery is self-sufficient — the user does not need to bring along the original `.relicario/salt`. The salt is not secret; storing it in the QR is not a confidentiality concern, and excluding it would tie recovery to a specific repo clone, which is the wrong invariant.
|
||||||
|
|
||||||
|
QR encoding: byte mode, error-correction level **M** (15% recovery — comfortable for paper-and-camera workflows). Payload + ECC fits in QR version 6 (41×41 modules, ≈ 30 mm at typical 300 DPI). Plenty of room.
|
||||||
|
|
||||||
|
### `RecoveryKdfParams` — type-level params floor
|
||||||
|
|
||||||
|
New type in `crates/relicario-core/src/recovery_qr.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct RecoveryKdfParams {
|
||||||
|
argon2_m: u32, // private
|
||||||
|
argon2_t: u32, // private
|
||||||
|
argon2_p: u32, // private
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecoveryKdfParams {
|
||||||
|
pub const fn production() -> Self { /* m=65536, t=3, p=4 */ }
|
||||||
|
// No `new`, no `with_*`, no public field, no `Deserialize`.
|
||||||
|
// Test code that needs fast params must use a `#[cfg(test)]`-gated constructor.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the type-system enforcement of the "hard floor on KDF params" requirement. There is no runtime path — adversarial JSON, accidental `params.json` reuse, or developer error — that produces a `RecoveryKdfParams` with weak parameters. Test-only fast params (for unit and integration tests) are exposed via a feature-gated or `cfg(test)`-gated constructor; the exact mechanism (test feature flag vs. crate-internal helper accessed via a dedicated test-only re-export) is an implementation-time decision deferred to the plan, but the constraint is firm: no public path to weak params in release builds.
|
||||||
|
|
||||||
|
### Shared `normalize_passphrase` helper
|
||||||
|
|
||||||
|
Currently `derive_master_key` does NFC normalization inline (`crypto.rs:224-227`). Extract this into `pub(crate) fn normalize_passphrase(p: &[u8]) -> Vec<u8>` in `crypto.rs` and have both `derive_master_key` and the recovery KDF call it. Add a regression test that asserts the two paths use the same helper (a doctest or a test that compares both code paths' inputs to Argon2id is sufficient — the goal is to make drift fail loudly).
|
||||||
|
|
||||||
|
## Memory hygiene
|
||||||
|
|
||||||
|
All intermediate buffers are `Zeroizing<…>` end-to-end:
|
||||||
|
|
||||||
|
- `wrap_key` — `Zeroizing<[u8; 32]>` (already the convention; reuse `derive_master_key`'s pattern).
|
||||||
|
- The 32-byte `image_secret` going into the wrap — already wrapped in `Zeroizing` upstream by `imgsecret::extract`; the recovery path must not copy it into a non-Zeroizing buffer.
|
||||||
|
- The encrypted payload buffer (109 bytes, no plaintext) does not need Zeroizing — it's the artifact we display.
|
||||||
|
|
||||||
|
The wasm binding returns the encoded payload as `Vec<u8>` (the QR-encodable bytes) for the extension to render. The 32-byte `image_secret` never crosses the wasm boundary; only the encrypted blob does.
|
||||||
|
|
||||||
|
## Display + print pipeline (no on-disk path)
|
||||||
|
|
||||||
|
There is no API in any crate that writes a recovery payload to a file. Reviewer-visible invariant.
|
||||||
|
|
||||||
|
- **`relicario-core`** exposes `recovery_qr::generate(passphrase, image_secret) -> Vec<u8>` (returns the 109-byte payload). It does **not** expose `generate_to_file` or accept a `Path`.
|
||||||
|
- **`relicario-wasm`** exposes `generate_recovery_payload(passphrase, image_secret) -> Vec<u8>`. Same constraint.
|
||||||
|
- **`relicario-cli`** subcommand `recovery-qr generate` renders to TTY using a Unicode block-drawing QR (e.g. via `qrcode` crate's `render::unicode::Dense1x2`). Offers no `--out` flag. A `--print` flag pipes a PostScript QR to `lp` (Linux/macOS); on Windows the CLI's print path is best-effort and the in-app help recommends the extension's print flow instead, since the extension's `window.print()` integrates with the OS print dialog more cleanly than a one-off CLI shell-out.
|
||||||
|
- **Extension** routes to a dedicated `recovery-qr.html` page that renders the QR onto a `<canvas>`. Two buttons: **Display** (the page IS the display) and **Print** (calls `window.print()` on the same page with a `@media print` stylesheet that scales the canvas appropriately). No `<img>` or Blob URL — those create right-click-save attack vectors. The canvas itself is non-rightclick-save in practice but `oncontextmenu` is also blocked on this route as defense in depth.
|
||||||
|
|
||||||
|
The Windows print-spooler caveat (`C:\Windows\System32\spool\PRINTERS\` cache) is documented in the in-app copy on the Print button: "Display is recommended on Windows. The system print queue may briefly cache the QR before printing."
|
||||||
|
|
||||||
|
## Passphrase entropy floor
|
||||||
|
|
||||||
|
zxcvbn integration already exists in `crates/relicario-core/src/generators.rs` (`rate_passphrase` returning `score` and `guesses_log10`). This work wires it into the gate at vault init.
|
||||||
|
|
||||||
|
**Threshold:** zxcvbn `score >= 3` (= "safely unguessable: moderate protection from offline slow-hash scenarios", ≈ 10¹⁰ guesses). Score 4 is "very unguessable" and is the upper rung; we do not require it because user research consistently shows 4-word diceware (~51 bits, score 3) is the realistic ceiling for real-world adoption.
|
||||||
|
|
||||||
|
**Where enforced:**
|
||||||
|
|
||||||
|
| Surface | Enforcement |
|
||||||
|
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `relicario init` (CLI) | Hard gate — refuses to create the vault, returns exit code 2 with `RelicarioError::WeakPassphrase { score, required: 3 }`. Suggests using `relicario generate-passphrase` (which already produces score-4 BIP39 outputs). |
|
||||||
|
| Extension setup wizard, "create new vault" branch | Hard gate at the passphrase step. The wizard already shows zxcvbn feedback; this change makes the Next button refuse to advance below score 3. Mirrors the existing attach-flow's structure (see `2026-04-27-attach-existing-vault-design.md` Step 3a). |
|
||||||
|
| Existing vaults at unlock (CLI + extension) | Soft warning: "Your passphrase scores below the current entropy floor. Consider rotating it to enable a secure recovery QR." Non-blocking. Surfaces once per session. |
|
||||||
|
| `recovery-qr generate` | Pre-flight check: if the unlock passphrase scores below 3, print a stronger warning and require a `--force-weak-passphrase` flag to proceed. The warning explains: "A recovery QR generated with a weak passphrase is feasibly brute-forceable from a photograph or printout." |
|
||||||
|
|
||||||
|
The weak-passphrase warning copy is the same in CLI and extension to keep the threat narrative consistent.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario recovery-qr generate # interactive: prompts passphrase, displays QR in TTY
|
||||||
|
relicario recovery-qr generate --print # secondary: pipes to system printer
|
||||||
|
relicario recovery-qr unlock --payload <hex> # one-shot recover image_secret from a scanned QR's hex
|
||||||
|
# (caller decoded the QR; we accept the payload bytes)
|
||||||
|
relicario unlock --recovery-qr-payload <hex> # alternative: full unlock using recovery payload + passphrase,
|
||||||
|
# bypassing the reference-image prompt for this invocation only
|
||||||
|
```
|
||||||
|
|
||||||
|
The `unlock --recovery-qr-payload` form is the actual disaster-recovery flow: the user is on a fresh device with no reference image, has just scanned their printed QR with a phone, and pastes the hex payload to unlock. After successful unlock, the CLI prints a recovery-completion notice and a pointer to the re-establishment flow:
|
||||||
|
|
||||||
|
> Recovered image_secret. Your reference image is currently lost — re-embed the recovered secret into a new carrier JPEG before relying on it. Run: `relicario imgsecret embed --carrier <new.jpg> --out <reference.jpg>` (uses the secret recovered in this session).
|
||||||
|
|
||||||
|
This requires a **new CLI subcommand `relicario imgsecret embed`** that wraps the existing `imgsecret::embed` function (already in `relicario-core/src/imgsecret.rs` and exposed via wasm at `relicario-wasm/src/lib.rs:273`). The command takes a fresh carrier JPEG and writes a reference image carrying the in-session-recovered secret. Bringing this to the CLI is in-scope for this spec because the disaster-recovery flow is incomplete without a path to re-establish the primary factor; the extension's existing image-creation flow already covers the equivalent there.
|
||||||
|
|
||||||
|
### Extension
|
||||||
|
|
||||||
|
Vault tab grows a **Disaster recovery** section with one button: **Generate recovery QR**. Clicking opens `recovery-qr.html` in a popup window (not a modal — popup gives `window.print()` cleaner ownership of the print dialog). Page contents:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Recovery QR │
|
||||||
|
│ │
|
||||||
|
│ [ canvas-rendered QR ] │
|
||||||
|
│ │
|
||||||
|
│ Snap with your phone, or click Print. │
|
||||||
|
│ This QR alone cannot unlock your vault. │
|
||||||
|
│ Combined with your passphrase, it can. │
|
||||||
|
│ │
|
||||||
|
│ [ Print ] [ Done ] │
|
||||||
|
│ │
|
||||||
|
│ ⚠ Windows users: prefer Display over │
|
||||||
|
│ Print. The system print queue may │
|
||||||
|
│ briefly cache the QR. │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`Done` clears the canvas and closes the window. The wasm-returned 109-byte payload is held only in the popup's `window` scope; both `Done` and the `beforeunload` event handler zero it via `payload.fill(0)` before the window's JS context is torn down. (The 109-byte blob is encrypted, so its sensitivity is bounded by the passphrase strength regardless — but zeroing is cheap and removes one layer of "what if a browser extension snoops popup memory" worry.)
|
||||||
|
|
||||||
|
The init wizard's Step 3a (passphrase entry for new vaults) gains the score-3 hard gate — an inline change to `extension/src/setup/setup.ts` near where `rate_passphrase` is already called for the strength meter.
|
||||||
|
|
||||||
|
The unlock dialog gains a **Use recovery QR** link below the reference-image picker. Clicking opens a paste field for the hex payload; submitting recovers the image_secret in-process and continues the normal unlock flow with that recovered secret. After successful unlock, a banner suggests re-establishing the reference image.
|
||||||
|
|
||||||
|
### wasm bindings (additions to `relicario-wasm/src/lib.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn generate_recovery_payload(passphrase: &str, image_secret: &[u8]) -> Result<Vec<u8>, JsError>;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn unwrap_recovery_payload(passphrase: &str, payload: &[u8]) -> Result<Vec<u8>, JsError>;
|
||||||
|
// returns the 32-byte image_secret on success
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration & backwards compatibility
|
||||||
|
|
||||||
|
Additive only. No vault format change, no `params.json` change, no `manifest.enc` change. Existing vaults gain access to the feature on upgrade.
|
||||||
|
|
||||||
|
The passphrase entropy floor only gates **new** vault creation. Existing vaults (which may have weaker passphrases) continue to unlock normally; they receive a soft warning at unlock-time as described above. There is no forced rotation.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
`crates/relicario-core/src/recovery_qr.rs`:
|
||||||
|
|
||||||
|
1. **Round-trip:** `image_secret = bytes; payload = generate(passphrase, image_secret); recovered = unwrap(passphrase, payload); assert_eq!(image_secret, recovered)`.
|
||||||
|
2. **Wrong passphrase rejected:** `unwrap("wrong", payload)` returns `RelicarioError::Decrypt`, no information leaked about which bit was wrong.
|
||||||
|
3. **Tampered payload rejected:** flip a byte anywhere in the 109 bytes — payload rejects.
|
||||||
|
4. **Domain separation:** assert the recovery KDF output for a given `(passphrase, salt)` differs from `derive_master_key`'s output for that same passphrase paired with the all-zero image_secret and the same salt. This regression guards against accidental input-shape collisions.
|
||||||
|
5. **NFC parity:** passphrase encoded as NFC vs NFD recovers identically — and explicitly call `normalize_passphrase` from both paths in the test setup to assert the helper is the single source of truth.
|
||||||
|
6. **Weak-params unconstructable:** type-level — there is no public path to construct `RecoveryKdfParams` with `argon2_m < 65536`. Asserted by a compile-fail test (trybuild) or by the absence of a public constructor (sufficient on its own; trybuild is gravy).
|
||||||
|
|
||||||
|
`crates/relicario-cli/tests/recovery_qr.rs`:
|
||||||
|
|
||||||
|
7. **No `--out` or file-write flag exists:** assert the clap surface for `recovery-qr generate` has no flags accepting a path. Negative test on the help output.
|
||||||
|
8. **End-to-end:** init a vault, generate a recovery QR (hex form for test purposes), purge the reference image, run `unlock --recovery-qr-payload <hex>` with the passphrase, assert the vault opens.
|
||||||
|
|
||||||
|
`crates/relicario-cli/tests/entropy_floor.rs`:
|
||||||
|
|
||||||
|
9. **Init rejects weak passphrase:** `relicario init` with passphrase `"correcthorse"` exits with code 2 and `WeakPassphrase` error.
|
||||||
|
10. **Init accepts strong passphrase:** `relicario init` with a fresh BIP39 4-word passphrase succeeds.
|
||||||
|
11. **Existing weak vault unlocks with warning:** simulate an existing vault with a weak passphrase; unlock succeeds and emits the soft warning to stderr.
|
||||||
|
|
||||||
|
Extension tests (Playwright or equivalent, following existing extension test patterns):
|
||||||
|
|
||||||
|
12. **Wizard rejects weak passphrase:** Next button disabled until score ≥ 3.
|
||||||
|
13. **Recovery QR popup never writes a file:** assert no `<a download>` or Blob URL appears in the popup DOM.
|
||||||
|
14. **`Done` clears canvas:** after Done, `getImageData` on the canvas returns all-zero bytes.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None remaining at design time. Defer to implementation:
|
||||||
|
|
||||||
|
- The exact CLI flag spelling (`--recovery-qr-payload` vs `--recover` vs `--recovery <hex>`). To be settled when the unlock-flow plan is written.
|
||||||
|
- Whether the extension popup's recovery flow accepts photographed-QR upload (image → QR-decode → payload) or only manual hex paste. The spec ships hex-paste only; image upload + decode is a follow-up that needs its own threat-model pass (uploading an image to the extension reintroduces a file-write vector that this design carefully avoided).
|
||||||
287
docs/superpowers/test-runs/2026-04-24-1c-beta-manual-matrix.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Plan 1C-β (β₁ + β₂) — Manual Test Matrix
|
||||||
|
|
||||||
|
Walkthrough for validating the typed-item forms (β₁) and the custom-fields editor + vault-settings + generator-popover surfaces (β₂) on Chrome and Firefox.
|
||||||
|
|
||||||
|
Branch: `main` @ `783cb7c` (tags `plan-1c-beta1-complete`, `plan-1c-beta2-complete`).
|
||||||
|
Pre-req: α matrix already validated — this round assumes the foundation works and focuses on the new β surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-flight
|
||||||
|
|
||||||
|
- [ ] **P1.** Bundles built fresh:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario/extension
|
||||||
|
bun run build:all
|
||||||
|
```
|
||||||
|
Expected: "compiled with 2 warnings" (WASM size only) for each of Chrome (`dist/`) and Firefox (`dist-firefox/`).
|
||||||
|
|
||||||
|
- [ ] **P2.** Test suites green:
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario && cargo test --workspace
|
||||||
|
cd /home/alee/Sources/relicario/extension && bun run test
|
||||||
|
```
|
||||||
|
Expected: 155 Rust + 124 Vitest, all pass.
|
||||||
|
|
||||||
|
- [ ] **P3.** Throwaway vault ready (don't pollute your real history). Either reuse the α-validated test vault, or do a fresh `chrome.storage.local` clear and re-init via setup tab.
|
||||||
|
|
||||||
|
- [ ] **P4.** Reference JPEG on hand for unlock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading
|
||||||
|
|
||||||
|
### Chrome
|
||||||
|
- [ ] **L1.** `chrome://extensions` → "Load unpacked" → `extension/dist/`. (Or "Update" if already loaded — webpack regenerated everything.)
|
||||||
|
- [ ] **L2.** Toolbar icon visible. Click → unlock or setup.
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
- [ ] **L3.** `about:debugging#/runtime/this-firefox` → "Load Temporary Add-on" → `extension/dist-firefox/manifest.json`.
|
||||||
|
- [ ] **L4.** Toolbar icon visible. Click → unlock or setup.
|
||||||
|
|
||||||
|
> Run the entire matrix on Chrome first, then re-run **Section A (β₁ types)** and **Section B (β₂ surfaces)** on Firefox. Section C (cross-cutting) needs to pass on both browsers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section A — β₁ typed-item forms
|
||||||
|
|
||||||
|
For each new type the matrix checks: **add → list icon → detail render → field round-trip → edit → trash**. Login was validated in α; spot-check it under **A0**.
|
||||||
|
|
||||||
|
### A0. Login regression spot-check
|
||||||
|
|
||||||
|
- [ ] Open popup → "+ New" → Login.
|
||||||
|
- [ ] **Expected:** Form has title / url / username / password (with "gen" button) / TOTP secret (optional).
|
||||||
|
- [ ] Fill and save; verify it appears in the list and detail-view round-trips.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### A1. SecureNote
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → SecureNote. Title `wifi`. Body `SSID: foo<newline>Password: bar`. Save.
|
||||||
|
- [ ] **Expected list row:** 📝 (or note icon) + `wifi`.
|
||||||
|
- [ ] **Expected detail:** Body renders preserving newlines; reveal/copy works on the body.
|
||||||
|
- [ ] **Edit:** Change body; save; detail reflects new body; modified time bumps.
|
||||||
|
- [ ] **Trash:** Disappears from list. CLI cross-check: `relicario list --trashed | grep wifi`.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### A2. Identity
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → Identity. Title `Personal`. Fill at least: full name, email, phone. Leave some fields empty intentionally (e.g. address).
|
||||||
|
- [ ] **Expected:** Detail view renders only the fields you populated — empty fields should NOT show as blank rows. `core.address === undefined` not `""` (verify via CLI `relicario get Personal --show` if curious).
|
||||||
|
- [ ] **Edit:** Add a field that was previously empty; save; detail shows the new row.
|
||||||
|
- [ ] **Trash:** Soft-deletes.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### A3. Card
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → Card. Title `Visa Test`. Cardholder `J. DOE`. Number `4111111111111111` (the canonical Visa test number — brand should auto-detect to "Visa"). CVV `123`. Expiry `08 / 2029`. PIN `9999`.
|
||||||
|
- [ ] **Expected during edit:** Brand chip flips to "Visa" once 4+ digits are typed (BIN match on `4`).
|
||||||
|
- [ ] **Expected detail:** number/cvv/pin are concealed by default; reveal on each works; copy on each puts the value on clipboard. Expiry shows `08/2029`.
|
||||||
|
- [ ] **Wire-format check (CLI):** `relicario get "Visa Test" --show --json | jq '.core.expiry'` should be `{"month":8,"year":2029}` (numbers, not strings).
|
||||||
|
- [ ] **Trash:** Soft-deletes.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### A4. Key
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → Key. Title `gh-deploy`. Algorithm `ed25519` (free-text). Paste a multi-line ASCII key into key_material (any junk is fine — `-----BEGIN OPENSSH PRIVATE KEY-----\nblah\n-----END...`).
|
||||||
|
- [ ] **Expected:** key_material is concealed/textarea-style; reveal shows full content with line breaks intact; copy puts the multi-line value on clipboard verbatim.
|
||||||
|
- [ ] **Edit:** Append to algorithm string; save; detail reflects.
|
||||||
|
- [ ] **Trash:** Soft-deletes.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### A5. Totp — TOTP kind (6 digits)
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → Totp. Title `GitHub-2FA`. Secret `JBSWY3DPEHPK3PXP` (RFC 6238 vector). Kind: TOTP.
|
||||||
|
- [ ] **Expected detail signature block:** Big 6-digit code (rotates every 30s); countdown ring shrinks each tick; code refreshes at the rollover without a manual reload.
|
||||||
|
- [ ] **Cross-check:** `oathtool --totp -b JBSWY3DPEHPK3PXP` (or any TOTP authenticator) → matches what the popup shows for the same wall-clock second.
|
||||||
|
- [ ] **Copy:** "copy code" button puts current code on clipboard.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### A6. Totp — Steam Guard kind (5 alphanumeric)
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → Totp. Title `Steam`. Secret `JBSWY3DPEHPK3PXP` (any base32 will do for the test). Toggle kind to **Steam**.
|
||||||
|
- [ ] **Expected:** Form's `digits` field disappears or locks (Steam is fixed at 5).
|
||||||
|
- [ ] **Expected detail:** 5-character alphanumeric code (e.g. `H7K2C`). All chars from the Steam alphabet `23456789BCDFGHJKMNPQRTVWXY` (no `0`, `1`, `A`, `E`, `I`, `O`, `S`, `U`, `Z`, `L`).
|
||||||
|
- [ ] **Edit:** Switch kind to TOTP, save; detail flips to 6-digit decimal. Switch back to Steam; flips back to 5-char.
|
||||||
|
- [ ] **CRITICAL:** If switching kinds doesn't re-render the detail-view computed code correctly after save, that's a stale-state bug — file before continuing.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### A7. Document type — gating
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → Document.
|
||||||
|
- [ ] **Expected:** "Coming soon" placeholder (planned for γ). Back button returns to list. **Should not crash or render a partial form.**
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section B — β₂ surfaces
|
||||||
|
|
||||||
|
### B1. Custom fields editor — add path
|
||||||
|
|
||||||
|
- [ ] **Do:** Open any item form (Login is fine). Scroll to the disclosure labeled "custom fields ▸" (or similar). Click to expand.
|
||||||
|
- [ ] **Expected:** Disclosure expands; "+ section" / "+ field" controls appear.
|
||||||
|
- [ ] **Do:** Add a section named `recovery codes`. Add two fields under it: kind=`password` with label `code 1` value `aaaa-bbbb`, and kind=`concealed` with label `code 2` value `cccc-dddd`. Save.
|
||||||
|
- [ ] **Expected:** Detail view shows the typed Login rows first, then the `recovery codes` section header, then the two custom rows. Each concealed/password row has reveal + copy.
|
||||||
|
- [ ] **CLI cross-check:** `relicario get <item> --show --json | jq '.sections'` shows the section with both fields.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B2. Custom fields editor — edit path
|
||||||
|
|
||||||
|
- [ ] **Do:** Edit the same item. In the disclosure, remove `code 1`, edit `code 2`'s label to `recovery hash`, add a new `text` kind field labeled `notes` value `worked 2024-04`. Save.
|
||||||
|
- [ ] **Expected:** Detail reflects all three changes (one removed, one renamed, one added).
|
||||||
|
- [ ] **Edge:** A blank `label` field — does β₂ render as `(unnamed)` or reject save? (Spec says render; verify either is acceptable but consistent.)
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B3. Custom fields editor — kind sniff
|
||||||
|
|
||||||
|
- [ ] **Do:** On a fresh add of an Identity item (or any type), open custom fields. Add fields of each supported kind (text / password / concealed). For each, verify in detail view: `text` is plain visible; `password` and `concealed` are masked with reveal/copy.
|
||||||
|
- [ ] **Expected:** No reordering controls (β₂ scope), but adding a new field appends to end.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B4. Vault settings — open path via ⚙ picker
|
||||||
|
|
||||||
|
- [ ] **Do:** Click the ⚙ icon in the toolbar. β₂ split this into a picker.
|
||||||
|
- [ ] **Expected:** A small menu appears with two choices — **device settings** (capture toggle, prompt style, blacklist) and **vault settings** (retention/generator/origin-acks). Pick "vault settings".
|
||||||
|
- [ ] **Expected:** Vault settings screen renders with a back arrow.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B5. Vault settings — trash retention
|
||||||
|
|
||||||
|
- [ ] **Do:** In vault settings, change "trash retention" from default to `7 days`.
|
||||||
|
- [ ] **Expected:** Save button enables (was disabled because no diff).
|
||||||
|
- [ ] **Do:** Save; lock; re-unlock; reopen vault settings.
|
||||||
|
- [ ] **Expected:** Still `7 days` (decrypted from the persisted VaultSettings).
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B6. Vault settings — history retention
|
||||||
|
|
||||||
|
- [ ] **Do:** Change "field history retention" to `last 5` (or `30 days` if your build offers `last_n` selectors). Save.
|
||||||
|
- [ ] **Expected:** Persists across lock/unlock.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B7. Vault settings — generator preview + "configure"
|
||||||
|
|
||||||
|
- [ ] **Expected by default:** Generator preview line shows current saved default (e.g. `Random, 20 chars, lower+upper+digits+symbols, safe symbols`).
|
||||||
|
- [ ] **Do:** Click "configure ▾". Popover opens inline (anchored to the preview line).
|
||||||
|
- [ ] **Do:** Change kind to **BIP39**. Set word count to 8. Set separator to `-`. Set capitalization to `lower`.
|
||||||
|
- [ ] **Expected:** Preview-string in the popover refreshes per-keystroke (debounced); a sample generated phrase shows.
|
||||||
|
- [ ] **Do:** Click "save as default". Popover closes. Preview line on the vault-settings screen now reads `BIP39, 8 words, "-" separator, lower`.
|
||||||
|
- [ ] **Do:** Lock; re-unlock; reopen vault settings.
|
||||||
|
- [ ] **Expected:** Preview still shows BIP39 default.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B8. Generator popover — open from Login form
|
||||||
|
|
||||||
|
- [ ] **Do:** "+ New" → Login. Click the "gen" button next to the password field.
|
||||||
|
- [ ] **Expected:** Generator popover opens **inheriting the BIP39 default from B7**. Sample phrase visible.
|
||||||
|
- [ ] **Do:** Click "use this value".
|
||||||
|
- [ ] **Expected:** The Login form's password field gets the BIP39 phrase. Popover closes.
|
||||||
|
- [ ] **Edge:** Open popover; toggle kind to **Random**; popover refreshes with random preview; click "use this value" — random string lands in the field. (Toggling shouldn't permanently mutate the saved default.)
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B9. Generator popover — kind toggle round-trip
|
||||||
|
|
||||||
|
- [ ] **Do:** Open popover from a fresh Login form. Toggle Random ↔ BIP39 several times.
|
||||||
|
- [ ] **Expected each toggle:** Preview redraws; debounced request shape switches between `generate_password` and `generate_passphrase`.
|
||||||
|
- [ ] **Smoke:** No console errors on toggle.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B10. Vault settings — origin-ack revoke
|
||||||
|
|
||||||
|
- [ ] **Pre-req:** Have at least one acked origin (e.g. github.com from α step 6).
|
||||||
|
- [ ] **Do:** Vault settings → scroll to "autofill acks". Find the github.com row. Click "revoke".
|
||||||
|
- [ ] **Expected:** Row disappears (or shows "revoked").
|
||||||
|
- [ ] **Save** (β₂ batches changes). Lock; re-unlock; reopen.
|
||||||
|
- [ ] **Expected:** Row stays gone.
|
||||||
|
- [ ] **Do:** Navigate to github.com/login; click the autofill icon.
|
||||||
|
- [ ] **Expected:** **TOFU prompt re-fires** — the origin is no longer pre-acked.
|
||||||
|
- [ ] **CRITICAL:** If autofill silently succeeds without re-prompting, the revoke didn't actually clear `VaultSettings.autofill_origin_acks`.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B11. Vault settings — discard / no-op
|
||||||
|
|
||||||
|
- [ ] **Do:** Open vault settings. Don't change anything. Click back arrow.
|
||||||
|
- [ ] **Expected:** Returns to list with no save attempt (popup didn't network-request).
|
||||||
|
- [ ] **Do:** Open again; change something; click back without saving.
|
||||||
|
- [ ] **Expected:** Either a confirm prompt OR silent discard. Reopen; the change is gone (not persisted).
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### B12. ⚙ picker — device-settings path regression
|
||||||
|
|
||||||
|
- [ ] **Do:** ⚙ → "device settings".
|
||||||
|
- [ ] **Expected:** The α-era device settings screen appears (capture toggle, bar/toast style, blacklist). All controls still functional.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section C — Cross-cutting
|
||||||
|
|
||||||
|
### C1. Field history captured for new typed kinds
|
||||||
|
|
||||||
|
- [ ] **Do:** Edit the Card item from A3; rotate the cvv. Save.
|
||||||
|
- [ ] **Do:** Edit the Key item from A4; rotate key_material. Save.
|
||||||
|
- [ ] **Do:** Edit the Totp item from A5; rotate the secret. Save.
|
||||||
|
- [ ] **Expected (CLI):** `relicario get <each> --show --json | jq '.field_history'` has an entry for the rotated concealed/password field with old value + timestamp.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### C2. List icon parity per type
|
||||||
|
|
||||||
|
- [ ] **Do:** Scroll the populated list.
|
||||||
|
- [ ] **Expected:** Each row's icon matches its type. Login 🔑, SecureNote 📝, Identity 👤, Card 💳, Key 🗝, Totp ⏱ (or whatever the implementation chose — the matrix only checks consistency, not specific glyph).
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### C3. Search across new types
|
||||||
|
|
||||||
|
- [ ] **Do:** Use the search box; type a substring of an item title for each type.
|
||||||
|
- [ ] **Expected:** Each type-specific item is findable; the type chip/icon is correct in the filtered list.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### C4. Sync / git push round-trip
|
||||||
|
|
||||||
|
- [ ] **Do:** From your throwaway test vault host, after creating items A1–A6 and the custom-field item from B1, run a sync from the popup (sync icon).
|
||||||
|
- [ ] **Expected:** Push succeeds; `git log` on the test repo shows new commits.
|
||||||
|
- [ ] **Do:** From CLI in main worktree, `relicario sync` then `relicario list`.
|
||||||
|
- [ ] **Expected:** Same items visible. (Tests round-trip integrity of the new wire format on a real git host, not just localStorage.)
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
### C5. Firefox parity
|
||||||
|
|
||||||
|
- [ ] **Do:** Re-run Section A (A0–A7) and Section B (B1–B12) on the Firefox-loaded `dist-firefox/`.
|
||||||
|
- [ ] **Expected:** Behavior identical to Chrome.
|
||||||
|
- [ ] **Watch for:** WASM-loading drift (FF uses `initDefault(wasmUrl)` not `initSync` because background.js is persistent, not SW). Anything broken on FF that works on Chrome is a WASM-init bug.
|
||||||
|
- [ ] **Notes:** ___
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final acceptance
|
||||||
|
|
||||||
|
- [ ] **A1.** All Section A scenarios pass on Chrome.
|
||||||
|
- [ ] **A2.** All Section B scenarios pass on Chrome.
|
||||||
|
- [ ] **A3.** All Section A + B scenarios pass on Firefox.
|
||||||
|
- [ ] **A4.** Section C cross-cutting all pass.
|
||||||
|
- [ ] **A5.** Lint sweeps green:
|
||||||
|
```bash
|
||||||
|
git grep -n 'idfoto' extension/ # 0
|
||||||
|
git grep -n '@ts-nocheck' extension/src/ # 0
|
||||||
|
git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ # 0
|
||||||
|
git grep -n 'coming-soon\|Coming in' extension/src/popup/components/ # only 'document'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings / issues
|
||||||
|
|
||||||
|
Use this space to log anything weird. For each issue: file path + symptom + repro steps. Bug-fix commits go to main as you find them.
|
||||||
|
|
||||||
|
```
|
||||||
|
(fill in as you go)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
- [ ] All clean — proceed to brainstorm 1C-γ.
|
||||||
|
- [ ] Bugs found and patched on main; re-run affected sections.
|
||||||
|
- [ ] Bugs found that warrant a worktree (>3 commits to fix).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated 2026-04-24 — sources: spec `2026-04-22-relicario-extension-1c-beta1-design.md` §3.9, spec `2026-04-22-relicario-extension-1c-beta2-design.md` "Manual matrix", α matrix `2026-04-20-1c-alpha-manual-matrix.md`.*
|
||||||
124
docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Pre-v0.3.0 manual test checklist
|
||||||
|
|
||||||
|
Date: 2026-04-27
|
||||||
|
Scope: every change in `CHANGELOG.md`'s `Unreleased` section since `v0.2.0` (commits `a7dbf35`, `f79a67b`, `3f0f5b1`, `b951741`, `c66fd52`).
|
||||||
|
|
||||||
|
Purpose: smoke-walk the audit pass before drawing the line and tagging
|
||||||
|
v0.3.0. Treat as a logic-spot-check, not a regression suite — the
|
||||||
|
automated tests (`cargo test`, the extension's vitest suite) cover
|
||||||
|
everything covered by tests already; this list is the things that need
|
||||||
|
human eyeballs.
|
||||||
|
|
||||||
|
## CLI — new commands (commit `3f0f5b1`)
|
||||||
|
|
||||||
|
- [ ] `relicario status` inside an active vault — shows root path, item
|
||||||
|
counts (active / trashed), attachment count + total bytes, device
|
||||||
|
count, `git log -1` last-commit line.
|
||||||
|
- [ ] `relicario status` with at least one trashed item — trashed count
|
||||||
|
is non-zero; active count excludes it.
|
||||||
|
- [ ] `relicario history <query>` — masked by default (passwords show as
|
||||||
|
`••••`).
|
||||||
|
- [ ] `relicario history <query> --show` — values revealed in the clear.
|
||||||
|
- [ ] `relicario history <query> --field login_password` — filter works.
|
||||||
|
Also try the raw form (`--field core:login_password`) — both
|
||||||
|
should match.
|
||||||
|
- [ ] `relicario history <query>` on an item with no captured history —
|
||||||
|
prints "no history captured".
|
||||||
|
- [ ] `relicario detach <query> <aid>` — removes the attachment ref,
|
||||||
|
deletes the encrypted blob on disk, commits `detach: …`.
|
||||||
|
- [ ] `relicario detach <doc-item> <primary-aid>` — refuses with "use
|
||||||
|
`purge` instead".
|
||||||
|
- [ ] `relicario edit <totp-item>` — rotate issuer, label, then secret;
|
||||||
|
verify a `core:totp_secret` history entry is captured (visible via
|
||||||
|
`relicario history`).
|
||||||
|
- [ ] `relicario settings generator-defaults` (no flags) — prints
|
||||||
|
current defaults.
|
||||||
|
- [ ] `relicario settings generator-defaults --random --length 32` —
|
||||||
|
flips mode + length, persists across runs.
|
||||||
|
- [ ] `relicario settings generator-defaults --bip39 --words 7
|
||||||
|
--separator -` — mode flip persists.
|
||||||
|
- [ ] `relicario generate` inside vault — uses the stored defaults.
|
||||||
|
- [ ] `relicario generate --length 8` inside vault — explicit flag
|
||||||
|
overrides the stored default.
|
||||||
|
- [ ] `relicario generate` outside any vault — still works at hardcoded
|
||||||
|
defaults (length 20, BIP39 5 words). No unlock prompt.
|
||||||
|
|
||||||
|
## Extension — popup (commit `a7dbf35`)
|
||||||
|
|
||||||
|
- [ ] Settings view → "Sync now" — refresh succeeds with "synced ✓";
|
||||||
|
force a sync with a bad token to confirm the error string
|
||||||
|
surfaces.
|
||||||
|
- [ ] Item-list toolbar sync button — same coverage.
|
||||||
|
- [ ] Devices view on a fresh install whose `device_name` isn't on the
|
||||||
|
remote — banner appears.
|
||||||
|
- [ ] Click "Register this device" → enter a name → confirm → device
|
||||||
|
appears in the list, banner disappears.
|
||||||
|
- [ ] Verify keypair persists across SW restart (re-open popup; banner
|
||||||
|
should NOT return).
|
||||||
|
|
||||||
|
## Extension — vault tab parity (commit `a7dbf35`)
|
||||||
|
|
||||||
|
- [ ] Open `vault.html` (Ctrl+Shift+L or popup pop-out). All views
|
||||||
|
render: list, detail, add, edit, settings, settings-vault, trash,
|
||||||
|
devices, field-history.
|
||||||
|
- [ ] `register_this_device` works from the vault tab the same way as
|
||||||
|
the popup.
|
||||||
|
- [ ] Inactivity timer still fires when only the vault tab is open (no
|
||||||
|
popup activity).
|
||||||
|
- [ ] Wrong-extension sender check — install a second extension, send
|
||||||
|
a message; should be rejected. (Covered by `router.test.ts:373-384`
|
||||||
|
but worth one manual sanity run if time permits.)
|
||||||
|
|
||||||
|
## Setup wizard (commit `f79a67b` — pure-helper extraction)
|
||||||
|
|
||||||
|
- [ ] First-run new-vault path: zxcvbn meter still updates within ~150
|
||||||
|
ms of typing; strength label changes through the five tiers as
|
||||||
|
the passphrase strengthens.
|
||||||
|
- [ ] First-run attach path: passphrase / image rejection produces the
|
||||||
|
exact "Could not decrypt vault — wrong passphrase or reference
|
||||||
|
image." string (no oracle leak).
|
||||||
|
- [ ] Step 5 device registration completes without manual fallback when
|
||||||
|
the extension is reachable.
|
||||||
|
|
||||||
|
## Refactor — cmd_add / cmd_edit per-type helpers (commit `3f0f5b1`)
|
||||||
|
|
||||||
|
For each `ItemCore` variant: spin up the form, save, re-open, edit,
|
||||||
|
save, verify the on-disk item stays valid. Drives both `build_*_item`
|
||||||
|
and `edit_*`.
|
||||||
|
|
||||||
|
- [ ] Login (with embedded TOTP sub-config)
|
||||||
|
- [ ] SecureNote
|
||||||
|
- [ ] Identity
|
||||||
|
- [ ] Card
|
||||||
|
- [ ] Key
|
||||||
|
- [ ] Document (add via `attach`; `edit` should print the "use `attach`
|
||||||
|
/ `extract`" message)
|
||||||
|
- [ ] Standalone Totp
|
||||||
|
|
||||||
|
## Build / test gates
|
||||||
|
|
||||||
|
- [ ] `cargo test` — all green.
|
||||||
|
- [ ] `cargo test -p relicario-cli --test basic_flows` (and the other
|
||||||
|
named integration tests) — green individually.
|
||||||
|
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` —
|
||||||
|
succeeds.
|
||||||
|
- [ ] Extension Chrome build (`webpack`) — produces a loadable
|
||||||
|
extension.
|
||||||
|
- [ ] Extension Firefox build (`webpack.firefox.config.js`) — produces
|
||||||
|
a loadable extension.
|
||||||
|
- [ ] Load in Chrome, load in Firefox, smoke-unlock an existing vault.
|
||||||
|
|
||||||
|
## Architecture-docs sanity (commit `c66fd52`)
|
||||||
|
|
||||||
|
- [ ] Spot-check three line-number citations from each ARCHITECTURE.md
|
||||||
|
against live code (drift is the silent killer — line-numbered
|
||||||
|
docs rot fastest). Suggested:
|
||||||
|
- `service-worker/index.ts:20` (lazy WASM init)
|
||||||
|
- `crypto.rs:59` (`VERSION_BYTE = 0x02`)
|
||||||
|
- `helpers.rs:48-52` (hardened-`git` `-c` flags)
|
||||||
|
|
||||||
|
## Sign-off
|
||||||
|
|
||||||
|
When every box above is checked, the audit pass is good to tag as
|
||||||
|
v0.3.0. Anything that fails goes back into `Unreleased` as a fix
|
||||||
|
commit before the tag.
|
||||||
831
extension/ARCHITECTURE.md
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
# Architecture: relicario extension
|
||||||
|
|
||||||
|
> Strategic-depth doc for the `extension/` codebase. Pairs with `/CLAUDE.md`
|
||||||
|
> at the repo root (project-level summary) and the typed-items design spec
|
||||||
|
> under `docs/superpowers/specs/`. Things that are easy to recover from
|
||||||
|
> reading code are deliberately omitted; things that are not — invariants,
|
||||||
|
> multi-file control flow, design rationale — go here.
|
||||||
|
|
||||||
|
## What this codebase is for
|
||||||
|
|
||||||
|
The extension is the browser-resident face of relicario: the same vault the
|
||||||
|
`relicario` CLI manages, but rendered as Chrome MV3 / Firefox WebExtension
|
||||||
|
UI plus a content-script autofill surface. It does not invent its own data
|
||||||
|
model or crypto — `crates/relicario-core` compiled to WASM
|
||||||
|
(`extension/wasm/relicario_wasm.js` + `relicario_wasm_bg.wasm`) holds the
|
||||||
|
KDF, AEAD, manifest/item/settings (de)serialization, password generators,
|
||||||
|
TOTP, steganography, and field-history routines. The extension is, above
|
||||||
|
that core, three things: a message router and crypto fortress (the service
|
||||||
|
worker), a small UI shell that runs in the popup and a fullscreen vault
|
||||||
|
tab, and a content script that detects login forms and shuttles
|
||||||
|
already-resolved credentials into them.
|
||||||
|
|
||||||
|
Design intent is CLI parity. Every capability in the CLI is reachable from
|
||||||
|
the extension; the popup is the everyday surface (unlock, search, fill,
|
||||||
|
TOTP, generator, capture); heavy workflows (setup wizard, vault-level
|
||||||
|
settings, trash, devices, future backup/restore and importer) live in the
|
||||||
|
fullscreen vault tab so they have screen real estate without the popup's
|
||||||
|
600px constraint. Both Chrome MV3 and Firefox WebExtension are first-class
|
||||||
|
build targets — `manifest.json` (Chrome) and `manifest.firefox.json`
|
||||||
|
(Firefox) differ only in the manifest envelope; the same TypeScript bundles
|
||||||
|
back both.
|
||||||
|
|
||||||
|
## Bundle structure
|
||||||
|
|
||||||
|
Webpack produces five entry points in the Chrome build, four in the
|
||||||
|
Firefox build (the vault tab is Chrome-only for the moment). Verify in
|
||||||
|
`extension/webpack.config.js` and `extension/webpack.firefox.config.js`.
|
||||||
|
|
||||||
|
| Bundle | Entry | Sandbox | Has WASM access? |
|
||||||
|
| ------------------ | -------------------------------------- | ------------------ | --------------------- |
|
||||||
|
| `service-worker` | `src/service-worker/index.ts` | extension SW / bg | yes — initialized lazily on first message |
|
||||||
|
| `popup` | `src/popup/popup.ts` | popup.html | no — goes through SW |
|
||||||
|
| `vault` | `src/vault/vault.ts` (Chrome only) | vault.html (tab) | no — goes through SW |
|
||||||
|
| `setup` | `src/setup/setup.ts` | setup.html (tab) | yes — direct dynamic import (predates SW handle) |
|
||||||
|
| `content` | `src/content/detector.ts` | host page (top frame only by router check) | no |
|
||||||
|
|
||||||
|
### What each bundle owns
|
||||||
|
|
||||||
|
- **service-worker** — the only place a vault `SessionHandle` and
|
||||||
|
decrypted `Manifest` ever live. Initializes WASM lazily on the first
|
||||||
|
message (`service-worker/index.ts:20`). Every other bundle goes through
|
||||||
|
this bundle for crypto. It also implements both `GitHost`s, owns the
|
||||||
|
inactivity timer (`session-timer.ts`), and reads/writes
|
||||||
|
`chrome.storage.local` for device-local state.
|
||||||
|
- **popup** — small MV3 popup at `popup.html`. Locked-or-list state
|
||||||
|
machine, search/sort/edit, attachments + TOTP. Cannot access
|
||||||
|
`SessionHandle` directly — every operation is a `chrome.runtime.sendMessage`
|
||||||
|
to the SW.
|
||||||
|
- **vault** — fullscreen "desktop-like" sidebar+pane shell. Imports the
|
||||||
|
same component renderers as the popup via the `StateHost` service
|
||||||
|
locator (see Cross-cutting). The vault tab is Chrome-only because
|
||||||
|
Firefox MV3 still treats `chrome.tabs.create` to extension pages
|
||||||
|
differently and the popup pop-out wasn't worth the cost yet.
|
||||||
|
- **setup** — first-run wizard. Lives in its own page (`setup.html`)
|
||||||
|
rather than the popup so the carrier-image upload + zxcvbn meter +
|
||||||
|
remote-host probing all have room. Loads WASM directly because it must
|
||||||
|
do crypto before any extension config exists for the SW to read
|
||||||
|
(`setup.ts:27`).
|
||||||
|
- **content** — injected into every page (`<all_urls>`) at
|
||||||
|
`document_idle`. Detects login forms, paints a small "id" icon, runs
|
||||||
|
the autofill picker / TOFU hint inside closed Shadow DOMs, and prompts
|
||||||
|
on form submit to save or update credentials. Cannot decrypt — the
|
||||||
|
SW always returns already-resolved `{ username, password }` payloads.
|
||||||
|
|
||||||
|
### Output trees
|
||||||
|
|
||||||
|
`webpack.config.js` writes to `dist/` and copies both
|
||||||
|
`relicario_wasm_bg.wasm` and `relicario_wasm.js` next to the bundles so
|
||||||
|
the SW's `chrome.runtime.getURL('relicario_wasm_bg.wasm')` resolves and
|
||||||
|
the setup page's dynamic `import('../relicario_wasm.js')` works. The
|
||||||
|
Firefox config writes to `dist-firefox/`, swaps in the Firefox manifest
|
||||||
|
under the name `manifest.json`, and skips the vault entry. Both pin
|
||||||
|
`experiments.asyncWebAssembly: true`. The Chrome content_security_policy
|
||||||
|
keeps `'wasm-unsafe-eval'` for extension pages (necessary for the WASM
|
||||||
|
init in setup.ts and the SW).
|
||||||
|
|
||||||
|
### WASM module
|
||||||
|
|
||||||
|
The wasm-pack output lives at `extension/wasm/`. Built from
|
||||||
|
`crates/relicario-wasm` (see project-root `CLAUDE.md`). The exported
|
||||||
|
surface — `unlock`, `lock`, `manifest_encrypt/decrypt`, `item_encrypt/decrypt`,
|
||||||
|
`settings_encrypt/decrypt`, `attachment_encrypt/decrypt`,
|
||||||
|
`embed_image_secret`, `extract_image_secret`, `totp_compute`, the
|
||||||
|
generators, `rate_passphrase`, `generate_device_keypair`, and the opaque
|
||||||
|
`SessionHandle` class — is enumerated in
|
||||||
|
`extension/wasm/relicario_wasm.d.ts`. Two patterns matter:
|
||||||
|
|
||||||
|
1. The SW initializes via `initSync(new WebAssembly.Module(bytes))` when
|
||||||
|
running as a real service worker (no top-level await), and the
|
||||||
|
default async `initDefault(url)` path otherwise (jest-style harness or
|
||||||
|
fallback). See `service-worker/index.ts:24-35`.
|
||||||
|
2. Setup uses `import(/* webpackIgnore: true */ '../relicario_wasm.js')`
|
||||||
|
so webpack doesn't try to inline the runtime — it's served as a flat
|
||||||
|
sibling file (`setup.ts:30-33`).
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
### `src/popup/`
|
||||||
|
|
||||||
|
- `popup.ts` — entry. Owns the popup state machine (`View` enum:
|
||||||
|
`locked | list | detail | add | edit | settings | settings-vault | trash
|
||||||
|
| devices | field-history`), captures the active tab at popup-open for
|
||||||
|
TOCTOU-safe fill (`popup.ts:230-233`), translates cryptic backend errors
|
||||||
|
to user-readable strings (`humanizeError`, `popup.ts:135-160`), and
|
||||||
|
registers itself as the shared `StateHost`.
|
||||||
|
- `index.html` / `styles.css` — markup + dark monospace theme.
|
||||||
|
|
||||||
|
### `src/popup/components/`
|
||||||
|
|
||||||
|
The popup UI. Each module exports a `renderXxx(app: HTMLElement)` and,
|
||||||
|
where it owns disposable resources (timers, DOM listeners), a
|
||||||
|
`teardown()` that the dispatcher in `popup.ts` and `vault.ts` calls
|
||||||
|
before any new render.
|
||||||
|
|
||||||
|
- `unlock.ts` — passphrase input + Enter-to-submit. Calls `unlock` SW
|
||||||
|
message; on success, fetches `list_items` and navigates to `list`.
|
||||||
|
- `item-list.ts` — toolbar (search/new/sync/lock/settings) + virtualized-ish
|
||||||
|
row list. Owns the keyboard navigation handler (`/`, `+`, arrow keys,
|
||||||
|
Enter, Esc) and the settings-picker popover that splits "device
|
||||||
|
settings" from "vault settings".
|
||||||
|
- `item-detail.ts` / `item-form.ts` — type dispatchers; each delegates to
|
||||||
|
one of `components/types/{login,secure-note,identity,card,key,document,totp}.ts`.
|
||||||
|
- `components/types/*.ts` — per-item-type detail+form pairs. Each exports
|
||||||
|
`renderDetail`, `renderForm`, and `teardown`. Uses the shared `fields.ts`
|
||||||
|
primitives (concealed rows, signature blocks, sections editor) and the
|
||||||
|
`attachments-disclosure.ts` widget.
|
||||||
|
- `fields.ts` — pure HTML-string primitives (`renderRow`,
|
||||||
|
`renderConcealedRow`, `renderSignatureBlock`, `renderSections*`)
|
||||||
|
consumed by every type. Mounting is the caller's job; after mount,
|
||||||
|
`wireFieldHandlers(scope)` binds the reveal/copy click handlers once.
|
||||||
|
- `generator-panel.ts` — inline password / passphrase generator. Mounts
|
||||||
|
inside any host element; round-trips knob changes through the SW's
|
||||||
|
`generate_password` / `generate_passphrase` (debounced 150ms). Has two
|
||||||
|
action-row modes: fill-field (cancel + use) and configure-defaults
|
||||||
|
(save-as-default).
|
||||||
|
- `attachments-disclosure.ts` — the per-item attachment list (edit/view
|
||||||
|
modes). Image-MIME rows lazy-load thumbnails as object URLs; teardown
|
||||||
|
revokes them. Per-item-count and per-vault soft/hard size caps are
|
||||||
|
enforced here client-side; the SW also enforces per-attachment max
|
||||||
|
bytes via WASM (defense in depth — see
|
||||||
|
`router/popup-only.ts:223-228`).
|
||||||
|
- `settings.ts` — device-local UX settings (capture toggle, prompt
|
||||||
|
style), trash/devices/sync-now buttons, blacklist editor.
|
||||||
|
- `settings-vault.ts` — vault-wide settings (retention, generator
|
||||||
|
defaults, autofill origin acks). Reads/writes via the SW's
|
||||||
|
`get_vault_settings` / `update_vault_settings`.
|
||||||
|
- `trash.ts` — soft-delete listing with restore + purge buttons.
|
||||||
|
- `devices.ts` — device list with revoke. Inline "register this device"
|
||||||
|
flow lives here (banner shown when current device is not in the list);
|
||||||
|
see commit `a7dbf35`.
|
||||||
|
- `field-history.ts` — audit-log of value changes on a single item;
|
||||||
|
driven by the SW's `get_field_history` which calls into WASM
|
||||||
|
`get_field_history(item_json)`.
|
||||||
|
|
||||||
|
### `src/vault/`
|
||||||
|
|
||||||
|
- `vault.ts` — fullscreen tab entry. Hash-based router (`#detail/<id>`,
|
||||||
|
`#add/<type>`, `#trash`, `#devices`, `#settings`, `#settings-vault`,
|
||||||
|
`#field-history`). Registers itself as the StateHost so all
|
||||||
|
`popup/components/*` renderers run unchanged. Maintains its own
|
||||||
|
`selectedItem` cache so hash navigation between already-loaded items
|
||||||
|
doesn't refetch.
|
||||||
|
- `vault.html` / `vault.css` — sidebar + pane layout.
|
||||||
|
|
||||||
|
### `src/setup/`
|
||||||
|
|
||||||
|
- `setup.ts` (1137 lines) — the wizard state machine. Six steps
|
||||||
|
(0..5): mode picker (new vault / attach this device), host type
|
||||||
|
(Gitea/GitHub), host config + connection test + repo probe, the
|
||||||
|
forking step 3 (create-vault vs attach-this-device), device name,
|
||||||
|
finish. Loads WASM directly. State-coupled `updateStrengthUi` stays
|
||||||
|
here because it walks the live wizard state.
|
||||||
|
- `setup-helpers.ts` (84 lines, extracted in commit `f79a67b`) — pure
|
||||||
|
helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms
|
||||||
|
debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the
|
||||||
|
`Strength` interface.
|
||||||
|
- `probe.ts` — best-effort detection of an existing vault on the remote
|
||||||
|
(any of `.relicario/salt`, `.relicario/params.json`, or `manifest.enc`
|
||||||
|
→ `exists: true`). Drives the warning banner that disambiguates "new
|
||||||
|
vault" vs "attach this device".
|
||||||
|
|
||||||
|
### `src/content/`
|
||||||
|
|
||||||
|
- `detector.ts` — entry. Finds password fields (skipping <20×10px
|
||||||
|
honeypots), associates each with a username field via a five-priority
|
||||||
|
cascade (autocomplete=username → autocomplete=email → type=email →
|
||||||
|
name/id pattern → preceding visible text input), injects the
|
||||||
|
`id`-icon, and starts a MutationObserver to rescan on SPA navigation.
|
||||||
|
- `icon.ts` — the in-page autofill icon and candidate picker /
|
||||||
|
TOFU-ack hint. Each overlay mounts in its own closed Shadow DOM
|
||||||
|
(`shadow.ts`). On icon click → `get_autofill_candidates`; one
|
||||||
|
candidate auto-fills (if origin is acked), multiple candidates show
|
||||||
|
the picker.
|
||||||
|
- `fill.ts` — listener for the SW-forwarded `fill_credentials` message.
|
||||||
|
Re-checks `location.href`'s hostname against the SW-provided
|
||||||
|
`expectedHost` (the second of two TOCTOU gates) and writes values
|
||||||
|
using the native HTMLInputElement setter trick so React/Vue pick up
|
||||||
|
the change.
|
||||||
|
- `capture.ts` — submit handler. Runs `check_credential` to ask whether
|
||||||
|
the (host, username, password) tuple is already in the vault; if not,
|
||||||
|
shows a save-or-update prompt in a closed Shadow DOM. The "Save"
|
||||||
|
button issues `capture_save_login` (content-callable); the SW figures
|
||||||
|
out add-vs-update and binds the new item to the sender's origin.
|
||||||
|
- `shadow.ts` — closed-mode `attachShadow` host helper. Comments here
|
||||||
|
enforce the "never innerHTML, never insertAdjacentHTML" rule —
|
||||||
|
page-supplied strings (hostname, username) only ever land via
|
||||||
|
`textContent`.
|
||||||
|
|
||||||
|
### `src/service-worker/`
|
||||||
|
|
||||||
|
- `index.ts` — thin entry. Wires the WASM init, owns the shared
|
||||||
|
`RouterState`, plumbs `chrome.runtime.onMessage` and
|
||||||
|
`chrome.commands.onCommand` (the `open-vault` keyboard command),
|
||||||
|
resets the inactivity timer on every popup-class message, and
|
||||||
|
broadcasts a `session_expired` notification when the timer fires.
|
||||||
|
- `router/index.ts` — single classify-and-dispatch function. Determines
|
||||||
|
whether a sender is popup/vault tab, setup tab, content top-frame, or
|
||||||
|
none-of-the-above (`router/index.ts:39-43`); routes to
|
||||||
|
`popup-only.ts` or `content-callable.ts`; rejects everything else
|
||||||
|
with `unauthorized_sender`. Setup tab is allowed exactly three
|
||||||
|
popup-only messages (`SETUP_ALLOWED`, `router/index.ts:23-27`):
|
||||||
|
`save_setup`, `rate_passphrase`, `is_unlocked`.
|
||||||
|
- `router/popup-only.ts` — handler match arms for every
|
||||||
|
`POPUP_ONLY_TYPES` message. The mutation-heavy ones (`add_item`,
|
||||||
|
`update_item`, `delete_item`) pull `SessionHandle` from
|
||||||
|
`session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate,
|
||||||
|
re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here
|
||||||
|
with its own captured-tab verification (see Key flows). New in
|
||||||
|
commit `a7dbf35`: `register_this_device`.
|
||||||
|
- `router/content-callable.ts` — handler match arms for every
|
||||||
|
`CONTENT_CALLABLE_TYPES` message. Origin always derived from
|
||||||
|
`sender.tab.url`, never from message fields. `capture_save_login`
|
||||||
|
has a defense-in-depth check that the existing item's `core.url`
|
||||||
|
hostname matches the sender's hostname before mutating, in case
|
||||||
|
manifest `icon_hint` has drifted from the underlying URL.
|
||||||
|
- `vault.ts` — typed-item vault operations. Crypto goes through the
|
||||||
|
ambient `wasm` module set at SW init by `setWasm`; nothing here
|
||||||
|
touches the master key directly. Includes
|
||||||
|
`findByHostname(manifest, hostname)` (the autofill matcher — coarse:
|
||||||
|
no www-stripping, no public-suffix), trash helpers
|
||||||
|
(`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and
|
||||||
|
attachment helpers (`addAttachmentToItem`, `removeAttachmentsFromItem`,
|
||||||
|
with manifest summary sync).
|
||||||
|
- `session.ts` — single module-scope `SessionHandle | null`. α assumes
|
||||||
|
one vault per install. Multi-vault would replace this with a `Map`
|
||||||
|
keyed by vault id.
|
||||||
|
- `session-timer.ts` — inactivity timer. Modes: `inactivity` (N
|
||||||
|
minutes since last popup-class message) and `every_time` (no timer;
|
||||||
|
rely on popup-close to clear). The router resets the timer for every
|
||||||
|
message that is NOT in `CONTENT_CALLABLE_TYPES`
|
||||||
|
(`service-worker/index.ts:76-78`).
|
||||||
|
- `git-host.ts` — abstract interface (`readFile`, `writeFile`,
|
||||||
|
`writeFileCreateOnly`, `deleteFile`, `listDir`, `lastCommit`,
|
||||||
|
`putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory.
|
||||||
|
`BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which
|
||||||
|
attachment writes switch from the Contents API to the Git Data API.
|
||||||
|
- `gitea.ts` / `github.ts` — the two GitHost implementations. Both use
|
||||||
|
the host's Contents API for files under threshold, and Git Data API
|
||||||
|
(blobs + tree + commit) for large attachment uploads. Auth differs
|
||||||
|
(Gitea: `token X`, GitHub: `Bearer X`). Both pre-check existence on
|
||||||
|
write to decide between create vs update; `writeFileCreateOnly`
|
||||||
|
refuses to clobber.
|
||||||
|
- `devices.ts` — read-modify-write helpers around
|
||||||
|
`.relicario/devices.json`. `addDevice` rejects duplicates by name;
|
||||||
|
`revokeDevice` rejects unknown names.
|
||||||
|
|
||||||
|
### `src/shared/`
|
||||||
|
|
||||||
|
- `messages.ts` — every `Request` and `Response` shape, plus the
|
||||||
|
capability sets `POPUP_ONLY_TYPES` and `CONTENT_CALLABLE_TYPES` the
|
||||||
|
router consults. Adding a new SW message requires (a) adding it to
|
||||||
|
the `PopupMessage` or `ContentMessage` union, AND (b) adding it to
|
||||||
|
the matching capability set, AND (c) adding a handler arm. Forget any
|
||||||
|
one of these and you get a silent rejection at runtime.
|
||||||
|
- `state.ts` — `StateHost` interface + module-scope singleton. Both
|
||||||
|
`popup.ts` and `vault.ts` register themselves on boot. All
|
||||||
|
`popup/components/*` import from here, never from popup.ts directly,
|
||||||
|
so the same render code runs in both bundles.
|
||||||
|
- `types.ts` — TypeScript mirrors of the Rust core's serde shapes:
|
||||||
|
`Item`, `ItemCore` (internally-tagged on `type`), `Field` and
|
||||||
|
`FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`,
|
||||||
|
`ManifestEntry`, `VaultSettings`, `GeneratorRequest`, etc. Hand-kept
|
||||||
|
in sync with `crates/relicario-core/src/{item.rs,item_types/,settings.rs}`.
|
||||||
|
- `base32.ts` — RFC 4648 base32 encode/decode for TOTP secrets. (Pure
|
||||||
|
TS; secrets never leave WASM after unlock anyway, but we store user
|
||||||
|
input as bytes via `base32Decode`.)
|
||||||
|
|
||||||
|
## Invariants & contracts
|
||||||
|
|
||||||
|
These are load-bearing rules. Some are enforced by code, some are
|
||||||
|
enforced by code-review and convention; both are listed.
|
||||||
|
|
||||||
|
- **Master key never crosses the WASM boundary.** It lives inside WASM
|
||||||
|
linear memory wrapped in `Zeroizing<[u8;32]>` (Rust side); JS holds
|
||||||
|
only the opaque `SessionHandle` (a `u32` index). `wasm.lock(handle)`
|
||||||
|
zeroes the slot; `session.clearCurrent()` calls it
|
||||||
|
(`session.ts:24-28`). No popup, vault, content, or setup code can
|
||||||
|
observe the key bytes.
|
||||||
|
- **Single SessionHandle per SW instance.** `session.ts` is module-scope.
|
||||||
|
α assumes one vault per install (deliberate; not an oversight).
|
||||||
|
- **Sender check on every SW message.** `router/index.ts:39-66` builds
|
||||||
|
`isPopup | isSetup | isContent` from `sender.url` and `sender.tab` /
|
||||||
|
`sender.frameId` / `sender.id`, then dispatches:
|
||||||
|
- popup-only types accept `popup.html` OR `vault.html` senders
|
||||||
|
(commit `a7dbf35` added `vault.html`).
|
||||||
|
- popup-only types ALSO accept `setup.html` for exactly three
|
||||||
|
messages: `save_setup`, `rate_passphrase`, `is_unlocked`
|
||||||
|
(`router/index.ts:23-27`).
|
||||||
|
- content-callable types require `sender.tab` defined,
|
||||||
|
`sender.frameId === 0` (top frame), AND
|
||||||
|
`sender.id === chrome.runtime.id` (same extension —
|
||||||
|
`router.test.ts:373-384` covers the third clause). Subframes and
|
||||||
|
other extensions are rejected.
|
||||||
|
- everything else: `unauthorized_sender`.
|
||||||
|
- **Capability sets are exhaustive.** Every message must appear in
|
||||||
|
exactly one of `POPUP_ONLY_TYPES` or `CONTENT_CALLABLE_TYPES`
|
||||||
|
(`shared/messages.ts:144-161`). A message in the union but in
|
||||||
|
neither set falls through to `unknown_message_type` and is silently
|
||||||
|
rejected. This is the easy mistake to make when adding a new
|
||||||
|
message type.
|
||||||
|
- **Content scripts cannot decrypt.** All paths from content end with
|
||||||
|
the SW returning either an opaque manifest projection (titles,
|
||||||
|
hostnames) or a fully-resolved `{ username, password }`. There is no
|
||||||
|
WASM in the content bundle and no pathway for content to obtain
|
||||||
|
ciphertext.
|
||||||
|
- **Origin TOFU on autofill.** Before returning credentials to a
|
||||||
|
content script, the SW checks
|
||||||
|
`VaultSettings.autofill_origin_acks[hostname]`
|
||||||
|
(`router/content-callable.ts:46-51`). Missing → return
|
||||||
|
`{ requires_ack: true, hostname }` so the icon shows the TOFU hint
|
||||||
|
and the user must open the popup to ack. The ack is recorded in
|
||||||
|
vault settings (encrypted, syncs across devices), keyed by hostname,
|
||||||
|
to a unix timestamp.
|
||||||
|
- **Two-stage TOCTOU close on `fill_credentials`.** The popup snapshots
|
||||||
|
`(capturedTabId, capturedUrl)` at popup-open (`popup.ts:230-233`).
|
||||||
|
The SW re-fetches the tab on fill, compares hostnames against the
|
||||||
|
snapshot AND against the item's own `core.url` hostname
|
||||||
|
(`router/popup-only.ts:397-410`), and forwards `expectedHost` along
|
||||||
|
with the credentials. The content script's fill listener
|
||||||
|
(`content/fill.ts:32-43`) re-checks `location.href`'s hostname
|
||||||
|
against `expectedHost` before typing — covering the gap between
|
||||||
|
`chrome.tabs.get` and `chrome.tabs.sendMessage`.
|
||||||
|
- **Origin binding on capture.** `capture_save_login` derives the
|
||||||
|
hostname from `sender.tab.url` only — never from message fields.
|
||||||
|
When updating an existing entry, the SW re-checks the entry's
|
||||||
|
`core.url` hostname against the sender's hostname; mismatch →
|
||||||
|
`origin_mismatch` (`router/content-callable.ts:113-117`). Otherwise a
|
||||||
|
drifted manifest `icon_hint` could rebind a password to the wrong
|
||||||
|
origin.
|
||||||
|
- **`writeFileCreateOnly` cannot clobber.** Setup uses it for the four
|
||||||
|
init artifacts (`.relicario/salt`, `.relicario/params.json`,
|
||||||
|
`manifest.enc`, `settings.enc`). If any exists, it throws — the
|
||||||
|
wizard catches and tells the user to switch to attach mode
|
||||||
|
(`setup.ts:888-893`).
|
||||||
|
- **AEAD failure surfaces as "wrong passphrase".** The setup attach
|
||||||
|
flow stages errors and rewrites failures during `derive session
|
||||||
|
handle` or `decrypt manifest` to the deliberately-ambiguous
|
||||||
|
"Could not decrypt vault — wrong passphrase or reference image."
|
||||||
|
(`setup.ts:396-401`). The popup `humanizeError` does the same for
|
||||||
|
`vault_locked`, `origin_mismatch`, `unauthorized_sender`, and
|
||||||
|
URL parse errors.
|
||||||
|
- **Inactivity timer modes.** `inactivity` resets on every
|
||||||
|
popup/vault/setup message (NOT on content messages —
|
||||||
|
`service-worker/index.ts:76-78`); fires after `minutes` of idle.
|
||||||
|
`every_time` has no timer; the popup-close handler is expected to
|
||||||
|
clear (handled implicitly because the popup re-checks `is_unlocked`
|
||||||
|
on each open).
|
||||||
|
- **Manifest mutation requires both writes.** Any item-changing handler
|
||||||
|
(`add_item`, `update_item`, `delete_item`, `restore_item`,
|
||||||
|
`purge_item`, `capture_save_login`, the attachment paths) writes
|
||||||
|
BOTH `items/<id>.enc` AND `manifest.enc` (the manifest entry is
|
||||||
|
derived via the local `itemToManifestEntry`). Forgetting the second
|
||||||
|
write breaks list/search/autofill until the next sync round-trip.
|
||||||
|
- **Both manifests stay in sync.** `manifest.json` (Chrome) and
|
||||||
|
`manifest.firefox.json` declare the same permissions, host
|
||||||
|
permissions, content scripts, and CSP. Drift is a portability bug.
|
||||||
|
|
||||||
|
## Key flows
|
||||||
|
|
||||||
|
### First-run setup (new vault)
|
||||||
|
|
||||||
|
`setup.ts`, six steps. WASM is loaded at the top of step 3.
|
||||||
|
|
||||||
|
1. **Step 0** — mode picker. `state.mode` ∈ `{ 'new', 'attach' }`.
|
||||||
|
2. **Step 1** — host type (Gitea / GitHub) + per-host instructions.
|
||||||
|
3. **Step 2** — host URL + repo path + API token. Click "test
|
||||||
|
connection" → `gitHost.listDir('')` succeeds → `probeVault(host)`
|
||||||
|
detects existing vault. Banner disambiguates: empty repo + new
|
||||||
|
mode = OK; populated repo + new mode = warn (would clobber);
|
||||||
|
empty repo + attach mode = warn (no vault to attach to).
|
||||||
|
4. **Step 3 (new branch)** — carrier JPEG + passphrase + confirm.
|
||||||
|
zxcvbn meter via SW `rate_passphrase` on a 150ms debounce
|
||||||
|
(`setup-helpers.ts:54-63`). Submit gate requires score ≥ 3 AND
|
||||||
|
passphrases match.
|
||||||
|
1. `crypto.getRandomValues(imageSecret)` — fresh 32-byte secret.
|
||||||
|
2. `wasm.embed_image_secret(carrierBytes, imageSecret)` → reference
|
||||||
|
JPEG bytes (DCT-embedded via central-embed; see core spec).
|
||||||
|
3. `crypto.getRandomValues(salt)` — fresh 32-byte vault salt.
|
||||||
|
4. `wasm.unlock(passphrase, referenceJpeg, salt, paramsJson)` —
|
||||||
|
Argon2id derives master key inside WASM; returns `SessionHandle`.
|
||||||
|
Note: `unlock` takes JPEG bytes, not the raw 32-byte secret —
|
||||||
|
the WASM side extracts internally.
|
||||||
|
5. Encrypt empty manifest + default settings. `writeFileCreateOnly`
|
||||||
|
pushes salt, params, manifest.enc, settings.enc — refuses to
|
||||||
|
clobber.
|
||||||
|
6. `wasm.lock(handle)` — release. Advance to step 4.
|
||||||
|
5. **Step 3 (attach branch)** — reference JPEG + passphrase. Fetches
|
||||||
|
salt + params + ciphertext, runs `wasm.unlock` and
|
||||||
|
`wasm.manifest_decrypt`. AEAD failure → "wrong passphrase or
|
||||||
|
reference image". Success → save handle in
|
||||||
|
`state.verifiedHandle`, advance.
|
||||||
|
6. **Step 4** — device name (default `${browser} on ${os}`).
|
||||||
|
7. **Step 5** — finish. If `chrome.runtime.sendMessage` reaches the
|
||||||
|
extension, "register this device" pushes everything in one go
|
||||||
|
(`setup.ts:1039-1112`):
|
||||||
|
1. `wasm.generate_device_keypair()` → `{ public_key_hex,
|
||||||
|
private_key_base64 }`.
|
||||||
|
2. `chrome.storage.local.set({ device_name, device_private_key })`.
|
||||||
|
3. `save_setup` SW message → `chrome.storage.local.set({ vaultConfig,
|
||||||
|
imageBase64 })`.
|
||||||
|
4. `addDevice(host, ...)` → read-modify-write
|
||||||
|
`.relicario/devices.json`.
|
||||||
|
5. `wasm.lock(verifiedHandle)` — release the attach-mode handle.
|
||||||
|
If the extension is NOT detected, the wizard offers to download the
|
||||||
|
reference JPEG and copy a JSON config blob to paste into the
|
||||||
|
extension manually.
|
||||||
|
|
||||||
|
### Unlock from popup
|
||||||
|
|
||||||
|
1. Popup opens → `chrome.tabs.query` snapshots active tab into
|
||||||
|
`state.capturedTabId` / `state.capturedUrl` (`popup.ts:231-233`).
|
||||||
|
Used later by `fill_credentials`.
|
||||||
|
2. `get_setup_state` → if not configured, opens setup tab and closes
|
||||||
|
popup.
|
||||||
|
3. `is_unlocked` → if unlocked, `list_items` + `get_vault_settings`,
|
||||||
|
navigate to `list`. Otherwise, navigate to `locked`.
|
||||||
|
4. User types passphrase → `unlock` SW message
|
||||||
|
(`router/popup-only.ts:38-55`):
|
||||||
|
1. Load `vaultConfig` + `imageBase64` from `chrome.storage.local`.
|
||||||
|
2. `createGitHost` if not already present.
|
||||||
|
3. `gitHost.readFile('.relicario/salt')` + `params.json` (cached on
|
||||||
|
`state.gitHost` for the SW lifetime).
|
||||||
|
4. `wasm.unlock(passphrase, imageBytes, salt, paramsJson)` →
|
||||||
|
`SessionHandle`.
|
||||||
|
5. Wipe `msg.passphrase` (best-effort — JS strings are immutable, but
|
||||||
|
we drop the reference).
|
||||||
|
6. `fetchAndDecryptManifest` and cache on `state.manifest`.
|
||||||
|
|
||||||
|
### Item create from popup
|
||||||
|
|
||||||
|
1. Form component (`components/types/login.ts` etc.) collects fields
|
||||||
|
and emits `add_item` with the full Item.
|
||||||
|
2. `router/popup-only.ts:74-83`:
|
||||||
|
1. `wasm.new_item_id()` — 16-char hex.
|
||||||
|
2. `wasm.item_encrypt(handle, JSON.stringify(item))` →
|
||||||
|
ciphertext.
|
||||||
|
3. `gitHost.writeFile('items/<id>.enc', ciphertext, "add: <title>")`.
|
||||||
|
4. Update `state.manifest.items[id]`; re-encrypt + write
|
||||||
|
`manifest.enc`.
|
||||||
|
3. Popup re-renders list with the new entry.
|
||||||
|
|
||||||
|
### Autofill (content-script flow)
|
||||||
|
|
||||||
|
1. `detector.ts` finds password fields, `icon.ts` injects an icon
|
||||||
|
inside a closed Shadow DOM near each.
|
||||||
|
2. User clicks icon → `get_autofill_candidates` (content-callable, no
|
||||||
|
`url` field — router derives hostname from `sender.tab.url`).
|
||||||
|
3. SW: `vault.findByHostname(manifest, senderHost)` matches
|
||||||
|
`manifest.items[i].icon_hint === hostname.toLowerCase()` (note: no
|
||||||
|
www-stripping, no PSL — coarse on purpose for α).
|
||||||
|
4. One candidate → content calls `get_credentials`. SW resolves origin
|
||||||
|
match (`router/content-callable.ts:42-44`) and TOFU
|
||||||
|
(`router/content-callable.ts:46-51`).
|
||||||
|
- First time on this hostname → `{ requires_ack: true, hostname }`.
|
||||||
|
`icon.ts` shows the in-page hint instructing the user to open
|
||||||
|
relicario; user opens popup, picks the item, and the SW path that
|
||||||
|
writes the credential calls `ack_autofill_origin`.
|
||||||
|
- Acked → `{ username, password }`. `fill.ts.fillFields` types
|
||||||
|
directly without a SW round-trip (content script IS the page
|
||||||
|
origin; no need to go through the SW just to write to its own
|
||||||
|
DOM). This is the only flow where credentials reach the page,
|
||||||
|
and the request was originated by the user via the icon click.
|
||||||
|
5. Multiple candidates → picker (also closed Shadow DOM).
|
||||||
|
Selection → same `get_credentials` path.
|
||||||
|
|
||||||
|
### Capture-save-login
|
||||||
|
|
||||||
|
1. `capture.ts` hooks `<form>` submit and any submit-shaped button.
|
||||||
|
2. On submit: `findUsernameValue(pwField)` + `password` →
|
||||||
|
`check_credential` (content-callable). SW returns one of:
|
||||||
|
`skip` (already match), `save` (no match), or
|
||||||
|
`update` (same username, different password).
|
||||||
|
3. If not skip, `capture.ts` shows a save-or-update prompt in a closed
|
||||||
|
Shadow DOM. Settings (capture style: bar/toast) fetched directly
|
||||||
|
from `chrome.storage.local` to avoid round-tripping through the SW
|
||||||
|
(which would also fail the router's content→popup-only check for
|
||||||
|
`get_settings`).
|
||||||
|
4. "Save" → `capture_save_login`. SW (`router/content-callable.ts:99-163`):
|
||||||
|
- Update path: existing `(host, username)` match → defense-in-depth
|
||||||
|
check that the item's `core.url` hostname matches sender hostname
|
||||||
|
→ re-encrypt only the password + modified, push.
|
||||||
|
- Add path: build a new Login bound to the sender's origin
|
||||||
|
(`title = senderHost`, `core.url = senderOrigin`), encrypt + push,
|
||||||
|
update manifest.
|
||||||
|
5. "Never" → `blacklist_site`. SW pushes hostname into
|
||||||
|
`chrome.storage.local.captureBlacklist`. Future submits on this
|
||||||
|
host short-circuit at step 2.
|
||||||
|
|
||||||
|
### Sync (manual, post-`a7dbf35`)
|
||||||
|
|
||||||
|
1. Settings view → "Sync now" (`components/settings.ts:83-92`) or
|
||||||
|
item-list toolbar "sync" (`item-list.ts:103-117`).
|
||||||
|
2. `sync` SW message → `vault.fetchAndDecryptManifest` re-pulls
|
||||||
|
`manifest.enc` from the host and re-decrypts. No git-side push or
|
||||||
|
merge — git host is the source of truth, and writes are immediate.
|
||||||
|
Sync is essentially "refresh the in-memory manifest cache".
|
||||||
|
3. Status text on the popup updates to "synced ✓" or
|
||||||
|
"sync failed: <error>".
|
||||||
|
|
||||||
|
### Device register from popup (post-`a7dbf35`)
|
||||||
|
|
||||||
|
1. Devices view detects `chrome.storage.local.device_name` is missing
|
||||||
|
from the remote device list → shows banner.
|
||||||
|
2. User clicks "Register this device" → inline name input
|
||||||
|
(`devices.ts:81-119`).
|
||||||
|
3. On confirm → `register_this_device` SW message
|
||||||
|
(`router/popup-only.ts:313-329`):
|
||||||
|
1. `wasm.generate_device_keypair()` →
|
||||||
|
`{ public_key_hex, private_key_base64 }`.
|
||||||
|
2. `chrome.storage.local.set({ device_name, device_private_key })`.
|
||||||
|
3. `devices.addDevice(host, ...)` → read-modify-write
|
||||||
|
`.relicario/devices.json`.
|
||||||
|
4. Devices view re-renders; banner gone.
|
||||||
|
|
||||||
|
### Session lock (timer-driven)
|
||||||
|
|
||||||
|
1. `service-worker/index.ts:51-58` registers `onExpired` callback at
|
||||||
|
SW boot.
|
||||||
|
2. Every popup-class message resets the timer (every content-callable
|
||||||
|
message does NOT — page-side traffic shouldn't keep the vault
|
||||||
|
unlocked; `service-worker/index.ts:76-78`).
|
||||||
|
3. After the configured idle window: callback fires →
|
||||||
|
`session.clearCurrent()` (zeroes WASM key) → `state.manifest = null`
|
||||||
|
→ broadcast `{ type: 'session_expired' }`.
|
||||||
|
4. Popup and vault tab listen for that broadcast and snap back to the
|
||||||
|
locked view (`popup.ts:299-307`, `vault.ts:521-531`).
|
||||||
|
|
||||||
|
### Trash + purge
|
||||||
|
|
||||||
|
1. `delete_item` is a soft-delete: the item gets a `trashed_at` and is
|
||||||
|
re-encrypted; the manifest entry mirrors that. List views filter
|
||||||
|
`trashed_at !== undefined`.
|
||||||
|
2. `list_trashed` returns trashed entries sorted newest-first.
|
||||||
|
3. `restore_item` clears `trashed_at` and bumps `modified`.
|
||||||
|
4. `purge_item` deletes the encrypted item + every attachment blob in
|
||||||
|
its `attachment_summaries`, removes the manifest entry, and rewrites
|
||||||
|
`manifest.enc`.
|
||||||
|
5. `purge_all_trash` purges every trashed item AND scans
|
||||||
|
`attachments/` for orphan blobs (not referenced by any remaining
|
||||||
|
manifest entry) and deletes them. Returns
|
||||||
|
`{ itemCount, orphanCount }`.
|
||||||
|
|
||||||
|
## Cross-cutting concerns
|
||||||
|
|
||||||
|
### State sharing across bundles
|
||||||
|
|
||||||
|
`shared/state.ts` is a service-locator for the popup component layer.
|
||||||
|
It defines a `StateHost` interface (`getState`, `setState`, `navigate`,
|
||||||
|
`sendMessage`, `escapeHtml`, `popOutToTab`, `isInTab`, `openVaultTab`)
|
||||||
|
and a single module-scope `host` slot. `popup.ts` and `vault.ts` each
|
||||||
|
call `registerHost({...})` at boot with their own implementations of
|
||||||
|
those methods. The `popup/components/*` files only know the locator;
|
||||||
|
they never import from `popup.ts` or `vault.ts`.
|
||||||
|
|
||||||
|
This is why every component renderer takes `app: HTMLElement`: the
|
||||||
|
host gives the component the mount point, and the locator gives the
|
||||||
|
component everything else (current state, message channel, navigation).
|
||||||
|
The same `renderItemDetail` runs unchanged in the 360px popup and the
|
||||||
|
fullscreen vault tab — the host's `getState()` projects different state
|
||||||
|
shapes that happen to share field names.
|
||||||
|
|
||||||
|
### Error surface
|
||||||
|
|
||||||
|
All SW handlers return `{ ok: true, data?: ... } | { ok: false, error: string }`.
|
||||||
|
Conventions:
|
||||||
|
|
||||||
|
- Vault-state errors (`vault_locked`, `item_not_found`, `not_a_login`,
|
||||||
|
`no_totp`, `attachment_not_found`) are bare snake_case strings the
|
||||||
|
popup can pattern-match in `humanizeError` (`popup.ts:135-160`).
|
||||||
|
- Origin / sender errors (`origin_mismatch`, `tab_navigated`,
|
||||||
|
`captured_tab_gone`, `unauthorized_sender`, `origin_changed`) are
|
||||||
|
also bare strings; they're the security-sensitive ones and must
|
||||||
|
remain testable by handler-level tests
|
||||||
|
(`router.test.ts:237-285`).
|
||||||
|
- Crypto failures bubble up as Rust error strings via wasm-bindgen.
|
||||||
|
AEAD authentication failures are deliberately conflated with
|
||||||
|
"wrong passphrase" (no oracle for "right passphrase, wrong image").
|
||||||
|
- Network / git-host failures bubble up as native `Error` instances
|
||||||
|
that the SW catches in `service-worker/index.ts:93-97` and flattens
|
||||||
|
to `{ ok: false, error: err.message }`.
|
||||||
|
|
||||||
|
### TS ↔ Rust type sync
|
||||||
|
|
||||||
|
`shared/types.ts` mirrors the Rust core's serde shapes. Internally-tagged
|
||||||
|
enums (`ItemCore`) match `#[serde(tag = "type")]`; adjacently-tagged
|
||||||
|
enums (`FieldValue`) match `#[serde(tag = "kind", content = "value")]`.
|
||||||
|
Optional fields use `?` because Rust's
|
||||||
|
`#[serde(skip_serializing_if = "Option::is_none")]` omits them and
|
||||||
|
`serde_wasm_bindgen` produces `undefined`. `r#type` Rust → `type` JSON
|
||||||
|
key. The mirror is hand-kept; if a Rust field changes, the TS shape
|
||||||
|
must be updated explicitly. Drift = silent runtime crash on first
|
||||||
|
encounter with a value the TS type says is impossible.
|
||||||
|
|
||||||
|
### Storage layout
|
||||||
|
|
||||||
|
**Local** (`chrome.storage.local`):
|
||||||
|
|
||||||
|
| Key | Set by | Holds |
|
||||||
|
| -------------------- | ------------------- | ----------------------------------------------- |
|
||||||
|
| `vaultConfig` | setup `save_setup` | `{ hostType, hostUrl, repoPath, apiToken }` |
|
||||||
|
| `imageBase64` | setup `save_setup` | reference JPEG bytes (base64). Re-read on every unlock. |
|
||||||
|
| `device_name` | setup / register | This device's name (must match a remote device record) |
|
||||||
|
| `device_private_key` | setup / register | base64 ed25519 private key. **Highest-value device-local secret.** |
|
||||||
|
| `relicarioSettings` | popup settings | `DeviceSettings` (capture toggle + style) |
|
||||||
|
| `captureBlacklist` | content `blacklist_site` / popup `remove_blacklist` | `string[]` of hostnames |
|
||||||
|
| `session_timeout` | popup `update_session_config` | `SessionTimeoutConfig` — restored on SW boot |
|
||||||
|
|
||||||
|
**Remote** (the git repo):
|
||||||
|
|
||||||
|
- `.relicario/salt` — 32-byte vault salt (KDF input).
|
||||||
|
- `.relicario/params.json` — Argon2id parameters (`m`, `t`, `p`).
|
||||||
|
- `.relicario/devices.json` — `{ devices: Device[] }`.
|
||||||
|
- `manifest.enc` — XChaCha20-Poly1305 ciphertext of the manifest.
|
||||||
|
- `items/<id>.enc` — per-item ciphertext.
|
||||||
|
- `attachments/<aid>.bin` — content-addressed encrypted attachment
|
||||||
|
blobs.
|
||||||
|
- `settings.enc` — vault settings (retention + caps + generator
|
||||||
|
defaults + `autofill_origin_acks`) ciphertext.
|
||||||
|
|
||||||
|
The remote is end-to-end encrypted; the host (Gitea/GitHub) sees only
|
||||||
|
opaque ciphertext. `chrome.storage.local` is NOT encrypted, so
|
||||||
|
`device_private_key` is the user's "this device" credential — losing
|
||||||
|
the local profile means revoking the device server-side and creating a
|
||||||
|
new keypair, but a non-zero local-attacker model. Documented in the
|
||||||
|
design spec.
|
||||||
|
|
||||||
|
### Two GitHosts
|
||||||
|
|
||||||
|
`gitea.ts` and `github.ts` implement the `GitHost` interface
|
||||||
|
(`git-host.ts:7-44`). They diverge on:
|
||||||
|
|
||||||
|
- Auth header (`token X` vs `Bearer X`).
|
||||||
|
- Read response shape (both base64-content; GitHub adds `\n` line
|
||||||
|
breaks the Gitea endpoint sometimes also adds — both implementations
|
||||||
|
strip).
|
||||||
|
- Update semantics (Gitea has separate POST-create / PUT-update;
|
||||||
|
GitHub's PUT is create-or-update, so the SHA presence is what
|
||||||
|
decides).
|
||||||
|
- Large-blob path. Both switch from Contents API to Git Data API
|
||||||
|
above `BLOB_THRESHOLD_BYTES`; the API shapes differ but both
|
||||||
|
produce a commit on the default branch.
|
||||||
|
|
||||||
|
Adding a third host (Codeberg, Gitlab) = implement `GitHost`, add a
|
||||||
|
case to `createGitHost` (`git-host.ts:74-84`), and surface the option
|
||||||
|
in `setup.ts` step 1.
|
||||||
|
|
||||||
|
## Test architecture
|
||||||
|
|
||||||
|
Tests run under `vitest` with `happy-dom`
|
||||||
|
(`extension/vitest.config.ts`). There is no real browser in CI; the
|
||||||
|
tests cover logic that is browser-API-shaped but doesn't actually
|
||||||
|
touch a real Chrome.
|
||||||
|
|
||||||
|
Patterns:
|
||||||
|
|
||||||
|
- **`globalThis.chrome` shim** at the top of each test
|
||||||
|
(`router/__tests__/router.test.ts:36-45`). Stubs only what the
|
||||||
|
test needs: `chrome.runtime.id`, `chrome.runtime.getURL`,
|
||||||
|
`chrome.storage.local.{get,set}`, `chrome.tabs.{get,sendMessage}`.
|
||||||
|
- **Module mocks via `vi.mock`** for the SW's `vault` and `session`
|
||||||
|
modules (`router/__tests__/router.test.ts:10-27`) so router tests
|
||||||
|
don't pull in WASM. The `vi.mock(..., importOriginal)` form keeps
|
||||||
|
the real `findByHostname`/`listItems` while overriding the
|
||||||
|
encrypt/decrypt boundary.
|
||||||
|
- **Component tests** (`popup/components/__tests__/*.test.ts`) mock
|
||||||
|
`shared/state` so `sendMessage` / `navigate` / etc. become
|
||||||
|
spies, and assert that the rendered DOM has the right shape and
|
||||||
|
that user actions emit the right SW messages.
|
||||||
|
|
||||||
|
Coverage highlights:
|
||||||
|
|
||||||
|
- `service-worker/router/__tests__/router.test.ts` — exhaustive sender
|
||||||
|
matrix: each popup-only and content-callable type tested from
|
||||||
|
popup, vault tab, setup tab, top-frame content, and an
|
||||||
|
"external"/wrong-extension-id sender. The vault-tab-as-popup
|
||||||
|
acceptance was added in commit `a7dbf35`. Setup-tab exception
|
||||||
|
scope (`save_setup`, `rate_passphrase`, `is_unlocked` allowed;
|
||||||
|
`unlock`, `fill_credentials` rejected) verified explicitly. Also
|
||||||
|
covers the `fill_credentials` TOCTOU verification, capture
|
||||||
|
add/update/origin-mismatch paths, get_totp on both Login.totp and
|
||||||
|
standalone Totp.config, and vault-settings get/set.
|
||||||
|
- `service-worker/__tests__/devices.test.ts` — devices.json
|
||||||
|
read/modify/write semantics (add/revoke).
|
||||||
|
- `service-worker/__tests__/git-host*.test.ts` — Contents API vs
|
||||||
|
Git Data API switching, SHA-on-update behavior.
|
||||||
|
- `service-worker/__tests__/session-timer.test.ts` — `inactivity`
|
||||||
|
vs `every_time` modes; reset/stop semantics.
|
||||||
|
- `service-worker/__tests__/trash.test.ts` — soft-delete, restore,
|
||||||
|
purge, orphan-blob cleanup.
|
||||||
|
- `popup/components/__tests__/devices.test.ts` — devices view including
|
||||||
|
the new register-this-device inline flow.
|
||||||
|
- `popup/components/__tests__/settings.test.ts` — sync button +
|
||||||
|
feedback (added in commit `a7dbf35`).
|
||||||
|
- `popup/components/__tests__/{attachments-disclosure,field-history,
|
||||||
|
fields,generator-panel,sections-{editor,render},settings-vault,trash}.test.ts`
|
||||||
|
— per-component coverage.
|
||||||
|
- `popup/components/types/__tests__/*.save.test.ts` — each item type's
|
||||||
|
form-to-Item serialization.
|
||||||
|
- `setup/__tests__/probe.test.ts` — vault-detection probe.
|
||||||
|
- `shared/__tests__/base32.test.ts` — RFC 4648 vectors.
|
||||||
|
|
||||||
|
**Test-vs-build gap**: tests run with happy-dom and stub crypto.
|
||||||
|
Browser-API semantics that depend on a real engine — service-worker
|
||||||
|
restart behavior, real `chrome.tabs.sendMessage` delivery timing,
|
||||||
|
`chrome.runtime.lastError` paths, MV3 cold-start bundle execution —
|
||||||
|
are NOT exercised. Treat tests as a logic-bug net, not a
|
||||||
|
browser-bug net; manual smoke-testing in both Chrome and Firefox is
|
||||||
|
still required before shipping.
|
||||||
|
|
||||||
|
## Gotchas & non-obvious decisions
|
||||||
|
|
||||||
|
- **Why the popup never loads WASM directly.** Crypto in one place
|
||||||
|
(the SW) means one set of bundle-size and CSP concerns. The popup
|
||||||
|
message round-trips are cheap enough; the architectural win is
|
||||||
|
worth more than the latency.
|
||||||
|
- **Why setup loads WASM directly anyway.** Setup needs to derive a
|
||||||
|
master key, encrypt an empty manifest, and push it to the remote
|
||||||
|
BEFORE `chrome.storage.local.vaultConfig` exists for the SW to read.
|
||||||
|
There's no `SessionHandle` to pass to the SW yet, and the SW's
|
||||||
|
`unlock` handler reads config from local storage — chicken-and-egg.
|
||||||
|
Setup's WASM module is independent of the SW's; both share the same
|
||||||
|
bytes but each has its own linear memory.
|
||||||
|
- **Why `vault.html` is treated as popup-class.** The audit flagged
|
||||||
|
that fullscreen workflows (settings-vault editor, future
|
||||||
|
backup/restore, future LastPass importer, devices) need more space
|
||||||
|
than the popup gives. Rather than introducing a third class of
|
||||||
|
sender, the router was extended to accept `vault.html` as a
|
||||||
|
popup-equivalent — the message vocabulary is identical, just the
|
||||||
|
surface is bigger. Commit `a7dbf35`.
|
||||||
|
- **Why setup.ts is huge but not split per-step.** A previous audit
|
||||||
|
recommended one-module-per-step; that risked introducing flow bugs
|
||||||
|
in a hand-tested wizard. Instead, only the pure helpers (no wizard
|
||||||
|
state) were extracted (`setup-helpers.ts`, commit `f79a67b`). The
|
||||||
|
step renderers and their event handlers stay inline because they
|
||||||
|
share `state` heavily and re-render on almost every input.
|
||||||
|
- **Why every "view" is just a render-into-`#app` function.** No
|
||||||
|
framework. The popup is small enough that a 50-line state machine
|
||||||
|
in `popup.ts` plus per-view render functions is shorter and faster
|
||||||
|
than React. The `StateHost` indirection lets the same components
|
||||||
|
render in the vault tab without changes — the price of "no
|
||||||
|
framework" is paid by `shared/state.ts`, which is 62 lines.
|
||||||
|
- **Why the SW caches `manifest` and `gitHost` in module memory.**
|
||||||
|
Service workers in MV3 are restartable but persistent during
|
||||||
|
activity; caching avoids re-decrypting the manifest on every
|
||||||
|
popup-open (which is constant) and re-fetching `salt` + `params`
|
||||||
|
on every unlock would be wasteful. On `lock`, `state.manifest` is
|
||||||
|
cleared (`router/popup-only.ts:60`) and on `session_expired` too
|
||||||
|
(`service-worker/index.ts:55-56`).
|
||||||
|
- **Why content scripts have direct `chrome.storage.local` access.**
|
||||||
|
The `storage` permission applies to all extension contexts. Content
|
||||||
|
uses it for capture style settings (`capture.ts:101-103`) because
|
||||||
|
routing through the SW would fail the router's
|
||||||
|
content→popup-only check for `get_settings`, and adding a
|
||||||
|
content-callable variant would expand the attack surface.
|
||||||
|
- **Why `device_private_key` lives in `chrome.storage.local` even
|
||||||
|
though it's a long-term secret.** The "device" IS the local
|
||||||
|
machine; the user is implicitly trusting whatever can read
|
||||||
|
`chrome.storage.local` (the same threat model as the SW's session
|
||||||
|
state). Promoting the key into the SW's WASM linear memory
|
||||||
|
wouldn't help — a local attacker capable of reading
|
||||||
|
`chrome.storage.local` is also capable of attaching a debugger to
|
||||||
|
the SW. The correct mitigation is OS-level (full-disk encryption)
|
||||||
|
and remote-side (revoke on loss).
|
||||||
|
- **Why `capture_save_login` is a single message with internal
|
||||||
|
add-vs-update branching.** Two messages (`capture_add` /
|
||||||
|
`capture_update`) would let a malicious page guess which one was
|
||||||
|
expected and craft a request to mutate an existing entry's password
|
||||||
|
on a sibling host. Funneling through one handler that derives
|
||||||
|
origin server-side and chooses the path itself eliminates that
|
||||||
|
class of bug.
|
||||||
|
- **Why `findByHostname` is intentionally coarse.** No
|
||||||
|
www.-stripping, no public-suffix matching: in α, `github.com` and
|
||||||
|
`www.github.com` saved logins are independent. Smarter matching
|
||||||
|
has UX failure modes (filling subdomain credentials cross-site)
|
||||||
|
that need design before code; tracked for 1C-β/γ. See
|
||||||
|
`service-worker/vault.ts:127-142`.
|
||||||
|
- **Why the inactivity timer ignores content-callable messages.**
|
||||||
|
A page making periodic background fetches (e.g. SSE, polling)
|
||||||
|
shouldn't keep the vault unlocked indefinitely. Only popup/vault
|
||||||
|
tab activity counts as "user is at the keyboard"
|
||||||
|
(`service-worker/index.ts:76-78`).
|
||||||
|
- **Why `is_unlocked` is in the setup-tab allowlist.** Setup's
|
||||||
|
step-5 detects whether the extension is reachable; pinging
|
||||||
|
`is_unlocked` is the cheapest available probe, and the response
|
||||||
|
is non-sensitive (a boolean). The two other allowed messages
|
||||||
|
(`save_setup`, `rate_passphrase`) are unavoidable.
|
||||||
|
- **Why fill goes through the SW for the credential resolution but
|
||||||
|
the actual DOM write happens in content.** The SW knows which
|
||||||
|
hostname the active tab is on and can match the right item; but
|
||||||
|
once the credentials are resolved and bound to `expectedHost`,
|
||||||
|
the content script is the only context with DOM access. The SW
|
||||||
|
could `chrome.tabs.executeScript` to inject a one-shot writer,
|
||||||
|
but that doubles the attack surface for no benefit — the
|
||||||
|
content script already has DOM access by the time the page is
|
||||||
|
loaded.
|
||||||
|
- **Why setup uses `webpackIgnore` to load WASM.** Webpack would
|
||||||
|
otherwise try to chunk-split or inline `relicario_wasm.js`, breaking
|
||||||
|
the wasm-pack runtime expectation that it lives at a stable URL
|
||||||
|
next to `relicario_wasm_bg.wasm`. The runtime calls
|
||||||
|
`WebAssembly.instantiateStreaming(fetch(URL))` against a
|
||||||
|
hardcoded path; we just hand it that path.
|
||||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 886 B After Width: | Height: | Size: 999 B |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,24 +1,30 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||||
<!-- 16x16-optimized: bolder strokes, simplified details, single gem
|
<defs>
|
||||||
facet for crisp pixels at toolbar size. -->
|
<radialGradient id="redThecaSm" cx="0.4" cy="0.35">
|
||||||
|
<stop offset="0%" stop-color="#9a1a1a"/>
|
||||||
|
<stop offset="100%" stop-color="#3a0a0a"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldRingSm" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#d2ab43"/>
|
||||||
|
<stop offset="50%" stop-color="#f5d97a"/>
|
||||||
|
<stop offset="100%" stop-color="#7c5719"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<!-- Base plate -->
|
<!-- Body + theca -->
|
||||||
<rect x="1" y="13" width="14" height="2" rx="0.5" fill="#58a6ff"/>
|
<circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/>
|
||||||
|
<circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/>
|
||||||
|
|
||||||
<!-- Arched reliquary body -->
|
<!-- Asterisk-as-3-bars -->
|
||||||
<path d="M 3 13
|
<g transform="translate(8, 9)" stroke="#f5d97a" stroke-width="1.2" stroke-linecap="round">
|
||||||
L 3 6
|
<line x1="0" y1="-3" x2="0" y2="3"/>
|
||||||
C 3 3.5, 5 2, 8 2
|
<line x1="-2.6" y1="-1.5" x2="2.6" y2="1.5"/>
|
||||||
C 11 2, 13 3.5, 13 6
|
<line x1="-2.6" y1="1.5" x2="2.6" y2="-1.5"/>
|
||||||
L 13 13 Z"
|
</g>
|
||||||
fill="#161b22"
|
<circle cx="8" cy="9" r="0.7" fill="#fff3cf"/>
|
||||||
stroke="#58a6ff"
|
|
||||||
stroke-width="1"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- Seal band -->
|
<!-- Fleur (3 tips) -->
|
||||||
<rect x="3" y="6" width="10" height="1" fill="#58a6ff"/>
|
<path d="M 8 0 L 7.2 2.5 L 8.8 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
<path d="M 5.6 2.5 L 6.5 1 L 7.3 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
<!-- Central gem — a simple filled diamond -->
|
<path d="M 10.4 2.5 L 9.5 1 L 8.7 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
<path d="M 8 8 L 10 10 L 8 12 L 6 10 Z" fill="#58a6ff"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 745 B After Width: | Height: | Size: 1.2 KiB |
@@ -1,38 +1,79 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none">
|
||||||
<!-- relicario: a reliquary — a vessel that holds precious things.
|
<defs>
|
||||||
Arched container with a horizontal seal band, a central gem
|
<radialGradient id="redTheca" cx="0.4" cy="0.35">
|
||||||
(the "relic"), standing on a base plate.
|
<stop offset="0%" stop-color="#9a1a1a"/>
|
||||||
Palette: gh-dark #0d1117/#161b22 background, #58a6ff primary,
|
<stop offset="100%" stop-color="#3a0a0a"/>
|
||||||
#79c0ff / #1f6feb gem facets. -->
|
</radialGradient>
|
||||||
|
<linearGradient id="goldRing" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#d2ab43"/>
|
||||||
|
<stop offset="50%" stop-color="#f5d97a"/>
|
||||||
|
<stop offset="100%" stop-color="#7c5719"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldHi" x1="0" x2="1">
|
||||||
|
<stop offset="0%" stop-color="#fde9a8"/>
|
||||||
|
<stop offset="100%" stop-color="#d2ab43"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<!-- Base plate / pedestal — extends slightly beyond the body. -->
|
<!-- Pedestal (compact) -->
|
||||||
<rect x="18" y="104" width="92" height="10" rx="2" fill="#58a6ff"/>
|
<ellipse cx="110" cy="226" rx="44" ry="5" fill="url(#goldRing)"/>
|
||||||
<rect x="18" y="112" width="92" height="2" fill="#1f6feb"/>
|
<rect x="78" y="212" width="64" height="14" rx="2" fill="url(#goldRing)"/>
|
||||||
|
<rect x="98" y="202" width="24" height="12" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="110" cy="208" rx="14" ry="3" fill="#7c5719"/>
|
||||||
|
<ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/>
|
||||||
|
|
||||||
<!-- Reliquary body: rounded arch over a rectangular casket. -->
|
<!-- Body, bezel, theca -->
|
||||||
<path d="M 28 104
|
<circle cx="110" cy="130" r="72" fill="url(#goldRing)"/>
|
||||||
L 28 54
|
<path d="M 110 58 A 72 72 0 0 0 38 130" stroke="#fde9a8" stroke-width="2" fill="none" opacity="0.6"/>
|
||||||
C 28 34, 44 20, 64 20
|
<circle cx="110" cy="130" r="60" fill="#7c5719"/>
|
||||||
C 84 20, 100 34, 100 54
|
<circle cx="110" cy="130" r="56" fill="url(#redTheca)"/>
|
||||||
L 100 104 Z"
|
<ellipse cx="86" cy="108" rx="16" ry="7" fill="#ffffff" opacity="0.14" transform="rotate(-30 86 108)"/>
|
||||||
fill="#161b22"
|
|
||||||
stroke="#58a6ff"
|
|
||||||
stroke-width="4"
|
|
||||||
stroke-linejoin="round"/>
|
|
||||||
|
|
||||||
<!-- Horizontal seal band across the arch-to-body transition. -->
|
<!-- Asterisk gem with pinwheel facets -->
|
||||||
<rect x="26" y="56" width="76" height="5" fill="#58a6ff"/>
|
<g transform="translate(110, 130)">
|
||||||
|
<g transform="rotate(0)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(60)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(120)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(180)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(240)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(300)">
|
||||||
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
||||||
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
||||||
|
</g>
|
||||||
|
<polygon points="0,-6 5.2,-3 5.2,3 0,6 -5.2,3 -5.2,-3" fill="#d2ab43" stroke="#7c5719" stroke-width="0.6"/>
|
||||||
|
<circle cx="-1.5" cy="-2" r="1.4" fill="#fff3cf"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
<!-- Small rivets at each end of the seal band. -->
|
<!-- Hinge collar -->
|
||||||
<circle cx="32" cy="58.5" r="2" fill="#0d1117"/>
|
<rect x="98" y="50" width="24" height="10" rx="2" fill="url(#goldRing)"/>
|
||||||
<circle cx="96" cy="58.5" r="2" fill="#0d1117"/>
|
<line x1="100" y1="55" x2="120" y2="55" stroke="#7c5719" stroke-width="0.8"/>
|
||||||
|
|
||||||
<!-- The relic: a faceted diamond/gem centered in the casket chamber.
|
<!-- Fleur-de-lis -->
|
||||||
Three tones suggest light hitting facets. -->
|
<g transform="translate(110, 50)">
|
||||||
<g transform="translate(64, 80)">
|
<rect x="-3.5" y="-12" width="7" height="12" fill="url(#goldRing)"/>
|
||||||
<path d="M 0 -18 L 16 0 L 0 22 L -16 0 Z" fill="#58a6ff"/>
|
<rect x="-16" y="-18" width="32" height="7" rx="1.5" fill="url(#goldRing)"/>
|
||||||
<path d="M 0 -18 L 16 0 L 0 0 Z" fill="#79c0ff"/>
|
<rect x="-3" y="-19" width="6" height="9" rx="0.8" fill="#7c5719"/>
|
||||||
<path d="M -16 0 L 0 -18 L 0 0 Z" fill="#1f6feb"/>
|
<path d="M 0 -18 Q -8 -36, -4 -54 Q -1 -62, 0 -64 Q 1 -62, 4 -54 Q 8 -36, 0 -18 Z" fill="url(#goldRing)"/>
|
||||||
<path d="M 0 22 L 16 0 L 0 0 Z" fill="#1f6feb" opacity="0.7"/>
|
<path d="M 0 -22 Q -2.5 -36, 0 -52 Q 2.5 -36, 0 -22 Z" fill="#7c5719" opacity="0.55"/>
|
||||||
|
<circle cx="0" cy="-66" r="2.5" fill="url(#goldHi)"/>
|
||||||
|
<path d="M -4 -18 Q -22 -22, -26 -38 Q -22 -50, -16 -50 Q -16 -38, -10 -32 Q -6 -28, -4 -28 Z" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="-25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(-20 -25 -44)"/>
|
||||||
|
<path d="M 4 -18 Q 22 -22, 26 -38 Q 22 -50, 16 -50 Q 16 -38, 10 -32 Q 6 -28, 4 -28 Z" fill="url(#goldRing)"/>
|
||||||
|
<ellipse cx="25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(20 25 -44)"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "relicario",
|
"name": "Relicario",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Two-factor encrypted password manager",
|
"description": "Two-factor encrypted password manager",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
@@ -30,5 +30,10 @@
|
|||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
},
|
},
|
||||||
|
"commands": {
|
||||||
|
"open-vault": {
|
||||||
|
"description": "Open Relicario vault"
|
||||||
|
}
|
||||||
|
},
|
||||||
"web_accessible_resources": []
|
"web_accessible_resources": []
|
||||||
}
|
}
|
||||||
|
|||||||
3528
extension/package-lock.json
generated
Normal file
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "relicario-extension",
|
"name": "relicario-extension",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jsqr": "^1.4.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.1.40",
|
"@types/chrome": "^0.1.40",
|
||||||
"copy-webpack-plugin": "^12.0",
|
"copy-webpack-plugin": "^12.0",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */
|
/* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */
|
||||||
.strength-bar.s0 .seg.i0 { background: #f85149; }
|
.strength-bar.s0 .seg.i0 { background: #ab2b20; }
|
||||||
.strength-bar.s1 .seg.i0,
|
.strength-bar.s1 .seg.i0,
|
||||||
.strength-bar.s1 .seg.i1 { background: #f08d49; }
|
.strength-bar.s1 .seg.i1 { background: #f08d49; }
|
||||||
.strength-bar.s2 .seg.i0,
|
.strength-bar.s2 .seg.i0,
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
.strength-label.s-very-weak { color: #f85149; }
|
.strength-label.s-very-weak { color: #ab2b20; }
|
||||||
.strength-label.s-weak { color: #f08d49; }
|
.strength-label.s-weak { color: #f08d49; }
|
||||||
.strength-label.s-fair { color: #d29922; }
|
.strength-label.s-fair { color: #d29922; }
|
||||||
.strength-label.s-good { color: #3fb950; }
|
.strength-label.s-good { color: #3fb950; }
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
.pass-help {
|
.pass-help {
|
||||||
background: #0d1117;
|
background: #0d1117;
|
||||||
border: 1px solid #21262d;
|
border: 1px solid #21262d;
|
||||||
border-left: 2px solid #1f6feb;
|
border-left: 2px solid #7c5719;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
transition: color 0.15s ease, opacity 0.15s ease;
|
transition: color 0.15s ease, opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.match-indicator.ok { color: #3fb950; }
|
.match-indicator.ok { color: #3fb950; }
|
||||||
.match-indicator.bad { color: #f85149; }
|
.match-indicator.bad { color: #ab2b20; }
|
||||||
|
|
||||||
/* Primary button explicitly dims when disabled so the gate is obvious. */
|
/* Primary button explicitly dims when disabled so the gate is obvious. */
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.test-result.pass { color: #3fb950; }
|
.test-result.pass { color: #3fb950; }
|
||||||
.test-result.fail { color: #f85149; }
|
.test-result.fail { color: #ab2b20; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function showPrompt(
|
|||||||
msgSpan.style.cssText = 'flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
|
msgSpan.style.cssText = 'flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
|
||||||
msgSpan.appendChild(document.createTextNode(`${actionLabel} login for `));
|
msgSpan.appendChild(document.createTextNode(`${actionLabel} login for `));
|
||||||
const hostStrong = document.createElement('strong');
|
const hostStrong = document.createElement('strong');
|
||||||
hostStrong.style.color = '#58a6ff';
|
hostStrong.style.color = '#d2ab43';
|
||||||
hostStrong.textContent = hostname;
|
hostStrong.textContent = hostname;
|
||||||
msgSpan.appendChild(hostStrong);
|
msgSpan.appendChild(hostStrong);
|
||||||
if (username) {
|
if (username) {
|
||||||
@@ -192,7 +192,7 @@ function showPrompt(
|
|||||||
const saveBtn = document.createElement('button');
|
const saveBtn = document.createElement('button');
|
||||||
saveBtn.textContent = actionLabel;
|
saveBtn.textContent = actionLabel;
|
||||||
saveBtn.style.cssText = [
|
saveBtn.style.cssText = [
|
||||||
'background:#1f6feb', 'color:#fff', 'border:none', 'padding:5px 14px',
|
'background:#7c5719', 'color:#fff', 'border:none', 'padding:5px 14px',
|
||||||
'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px',
|
'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px',
|
||||||
'white-space:nowrap',
|
'white-space:nowrap',
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ export function injectFieldIcons(
|
|||||||
const icon = document.createElement('div');
|
const icon = document.createElement('div');
|
||||||
icon.textContent = 'id';
|
icon.textContent = 'id';
|
||||||
icon.setAttribute('role', 'button');
|
icon.setAttribute('role', 'button');
|
||||||
icon.setAttribute('aria-label', 'relicario autofill');
|
icon.setAttribute('aria-label', 'Relicario autofill');
|
||||||
icon.style.cssText = [
|
icon.style.cssText = [
|
||||||
'width: 20px', 'height: 20px', 'line-height: 20px',
|
'width: 20px', 'height: 20px', 'line-height: 20px',
|
||||||
'text-align: center', 'font-size: 10px', 'font-weight: 700',
|
'text-align: center', 'font-size: 10px', 'font-weight: 700',
|
||||||
'font-family: monospace', 'color: #fff', 'background: #1f6feb',
|
'font-family: monospace', 'color: #fff', 'background: #7c5719',
|
||||||
'border-radius: 3px', 'cursor: pointer', 'user-select: none',
|
'border-radius: 3px', 'cursor: pointer', 'user-select: none',
|
||||||
'box-sizing: border-box',
|
'box-sizing: border-box',
|
||||||
].join('; ');
|
].join('; ');
|
||||||
@@ -177,7 +177,7 @@ function showPicker(
|
|||||||
|
|
||||||
/// TOFU origin-ack hint: credentials exist for this host but the user has
|
/// TOFU origin-ack hint: credentials exist for this host but the user has
|
||||||
/// never explicitly acknowledged autofill here. Instruct them to open
|
/// never explicitly acknowledged autofill here. Instruct them to open
|
||||||
/// relicario to confirm — we do not (and cannot) fill until ack-autofill
|
/// Relicario to confirm — we do not (and cannot) fill until ack-autofill
|
||||||
/// has been called from the popup.
|
/// has been called from the popup.
|
||||||
function showAckHint(hostname: string): void {
|
function showAckHint(hostname: string): void {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
@@ -200,8 +200,8 @@ function showAckHint(hostname: string): void {
|
|||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #58a6ff;';
|
title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #d2ab43;';
|
||||||
title.textContent = 'relicario';
|
title.textContent = 'Relicario';
|
||||||
hint.appendChild(title);
|
hint.appendChild(title);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
@@ -209,7 +209,7 @@ function showAckHint(hostname: string): void {
|
|||||||
const hostSpan = document.createElement('strong');
|
const hostSpan = document.createElement('strong');
|
||||||
hostSpan.textContent = hostname;
|
hostSpan.textContent = hostname;
|
||||||
body.appendChild(hostSpan);
|
body.appendChild(hostSpan);
|
||||||
body.appendChild(document.createTextNode(' — open relicario to confirm.'));
|
body.appendChild(document.createTextNode(' — open Relicario to confirm.'));
|
||||||
hint.appendChild(body);
|
hint.appendChild(body);
|
||||||
|
|
||||||
const close = document.createElement('div');
|
const close = document.createElement('div');
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', async () => {
|
||||||
|
const sendMessage = vi.fn();
|
||||||
|
const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
||||||
|
return { sendMessage, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure } from '../attachments-disclosure';
|
||||||
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
import type { AttachmentRef } from '../../../shared/types';
|
||||||
|
|
||||||
|
const REF1: AttachmentRef = { id: 'a1', filename: 'doc.pdf', mime_type: 'application/pdf', size: 12345, created: 1700000000 };
|
||||||
|
const REF2: AttachmentRef = { id: 'a2', filename: 'photo.png', mime_type: 'image/png', size: 240000, created: 1700000001 };
|
||||||
|
|
||||||
|
describe('attachments-disclosure render', () => {
|
||||||
|
it('renders empty state with no rows in edit mode', () => {
|
||||||
|
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
|
||||||
|
expect(html).toContain('attachments');
|
||||||
|
expect(html).toContain('+ attach file');
|
||||||
|
expect(html).not.toContain('attachment-row');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders rows + remove buttons in edit mode', () => {
|
||||||
|
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange: vi.fn() });
|
||||||
|
expect(html).toContain('doc.pdf');
|
||||||
|
expect(html).toContain('photo.png');
|
||||||
|
expect(html).toContain('×');
|
||||||
|
expect(html).toContain('attachment-row__thumb'); // image-mime row gets thumb hook
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders rows + download buttons in view mode (no add btn)', () => {
|
||||||
|
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
|
||||||
|
expect(html).toContain('↓');
|
||||||
|
expect(html).not.toContain('+ attach file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('attachments-disclosure wiring', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking + attach triggers file input click', () => {
|
||||||
|
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
|
||||||
|
const fileInput = document.querySelector('.attachments-disclosure__file-input') as HTMLInputElement;
|
||||||
|
const clickSpy = vi.spyOn(fileInput, 'click');
|
||||||
|
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
|
||||||
|
(document.querySelector('.attachment-add-btn') as HTMLButtonElement).click();
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking × calls onChange with the attachment removed', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
|
||||||
|
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
|
||||||
|
(document.querySelectorAll('.attachment-row__remove')[0] as HTMLElement).click();
|
||||||
|
expect(onChange).toHaveBeenCalledWith([REF2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking ↓ in view mode sends download_attachment', async () => {
|
||||||
|
vi.mocked(sendMessage).mockResolvedValueOnce({ ok: true, data: { bytes: new ArrayBuffer(10), filename: 'doc.pdf', mimeType: 'application/pdf' } });
|
||||||
|
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
|
||||||
|
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1], mode: 'view' });
|
||||||
|
(document.querySelector('.attachment-row__download') as HTMLElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(vi.mocked(sendMessage)).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'download_attachment',
|
||||||
|
itemId: 'i1',
|
||||||
|
attachmentId: 'a1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
132
extension/src/popup/components/__tests__/devices.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { renderDevices } from '../devices';
|
||||||
|
|
||||||
|
// Mock chrome.storage.local
|
||||||
|
// @ts-expect-error test harness
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: vi.fn().mockResolvedValue({ device_name: 'Chrome on Linux' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock popup module
|
||||||
|
vi.mock('../../../shared/state', () => ({
|
||||||
|
setState: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: vi.fn(() => false),
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sendMessage, navigate } from '../../../shared/state';
|
||||||
|
|
||||||
|
describe('devices view', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no devices', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { devices: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderDevices(app);
|
||||||
|
|
||||||
|
expect(app.innerHTML).toContain('No devices registered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders devices with "you" indicator on current device', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
devices: [
|
||||||
|
{ name: 'Chrome on Linux', public_key: 'abc', added_at: 1000 },
|
||||||
|
{ name: 'CLI', public_key: 'def', added_at: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderDevices(app);
|
||||||
|
|
||||||
|
expect(app.innerHTML).toContain('Chrome on Linux');
|
||||||
|
expect(app.innerHTML).toContain('← you');
|
||||||
|
expect(app.innerHTML).toContain('CLI');
|
||||||
|
// Current device should not have revoke button
|
||||||
|
const rows = app.querySelectorAll('.device-row');
|
||||||
|
expect(rows[0].querySelector('[data-revoke]')).toBeNull();
|
||||||
|
expect(rows[1].querySelector('[data-revoke]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows unregistered banner when current device not in list', async () => {
|
||||||
|
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
devices: [{ name: 'CLI', public_key: 'abc', added_at: 1000 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderDevices(app);
|
||||||
|
|
||||||
|
expect(app.innerHTML).toContain('This device is not registered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('back button navigates to list', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { devices: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderDevices(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking register button reveals an inline name input', async () => {
|
||||||
|
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderDevices(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
|
||||||
|
|
||||||
|
expect(app.querySelector<HTMLInputElement>('#register-name-input')).not.toBeNull();
|
||||||
|
expect(app.querySelector<HTMLButtonElement>('#register-confirm-btn')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirming register sends register_this_device with the entered name', async () => {
|
||||||
|
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
|
||||||
|
// Initial list_devices.
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } })
|
||||||
|
// register_this_device.
|
||||||
|
.mockResolvedValueOnce({ ok: true })
|
||||||
|
// Re-render's list_devices.
|
||||||
|
.mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }, { name: 'Test Browser', public_key: 'q', added_at: 2 }] } });
|
||||||
|
// Re-render also re-reads device_name from storage.
|
||||||
|
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Test Browser' });
|
||||||
|
|
||||||
|
await renderDevices(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
|
||||||
|
const input = app.querySelector<HTMLInputElement>('#register-name-input')!;
|
||||||
|
input.value = 'Test Browser';
|
||||||
|
app.querySelector<HTMLButtonElement>('#register-confirm-btn')!.click();
|
||||||
|
// Wait a microtask for the async handler to run.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({ type: 'register_this_device', name: 'Test Browser' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { renderFieldHistory, teardown } from '../field-history';
|
||||||
|
|
||||||
|
// Mock popup module
|
||||||
|
vi.mock('../../../shared/state', () => ({
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
historyItemId: 'item123',
|
||||||
|
selectedItem: { id: 'item123', title: 'Test Item', modified: 1000 },
|
||||||
|
})),
|
||||||
|
setState: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: vi.fn(() => false),
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sendMessage, navigate } from '../../../shared/state';
|
||||||
|
|
||||||
|
describe('field-history view', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = document.createElement('div');
|
||||||
|
teardown();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no history', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { history: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderFieldHistory(app);
|
||||||
|
|
||||||
|
expect(app.innerHTML).toContain('No history available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders history entries masked by default', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
history: [{
|
||||||
|
field_id: 'f1',
|
||||||
|
field_name: 'password',
|
||||||
|
current_value: 'secret123',
|
||||||
|
entries: [{ value: 'oldpass', changed_at: 500 }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderFieldHistory(app);
|
||||||
|
|
||||||
|
expect(app.innerHTML).toContain('••••••••••••');
|
||||||
|
expect(app.innerHTML).not.toContain('secret123');
|
||||||
|
expect(app.innerHTML).toContain('current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('back button navigates to detail', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { history: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderFieldHistory(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith('detail');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', async () => {
|
||||||
|
const escapeHtml = (s: string) => s
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
return { escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
renderRow,
|
renderRow,
|
||||||
renderConcealedRow,
|
renderConcealedRow,
|
||||||
@@ -67,9 +75,9 @@ describe('renderConcealedRow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('renderSignatureBlock', () => {
|
describe('renderSignatureBlock', () => {
|
||||||
it('default accent is blue', () => {
|
it('default accent is gold', () => {
|
||||||
const html = renderSignatureBlock({ children: '<p>hi</p>' });
|
const html = renderSignatureBlock({ children: '<p>hi</p>' });
|
||||||
expect(html).toContain('sig-block--blue');
|
expect(html).toContain('sig-block--gold');
|
||||||
expect(html).toContain('<p>hi</p>');
|
expect(html).toContain('<p>hi</p>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
169
extension/src/popup/components/__tests__/generator-panel.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', async () => {
|
||||||
|
const sendMessage = vi.fn();
|
||||||
|
return { sendMessage, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
|
||||||
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
import type { GeneratorRequest } from '../../../shared/types';
|
||||||
|
|
||||||
|
const DEFAULT_REQ: GeneratorRequest = {
|
||||||
|
kind: 'random',
|
||||||
|
length: 20,
|
||||||
|
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: { kind: 'safe_only' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupMount(): { parent: HTMLElement; trigger: HTMLElement } {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="parent">
|
||||||
|
<button id="trigger" aria-expanded="false">✨</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
parent: document.getElementById('parent')!,
|
||||||
|
trigger: document.getElementById('trigger')!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generator-panel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a panel with Random kind by default', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(document.querySelector('.gen-panel')).not.toBeNull();
|
||||||
|
expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends generate_password on knob change (debounced)', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const slider = document.querySelector('#gen-length') as HTMLInputElement;
|
||||||
|
slider.value = '32';
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls.filter(
|
||||||
|
([msg]) => (msg as { type: string }).type === 'generate_password',
|
||||||
|
);
|
||||||
|
const latest = calls[calls.length - 1]![0] as { request: GeneratorRequest };
|
||||||
|
expect(latest.request.kind).toBe('random');
|
||||||
|
if (latest.request.kind === 'random') {
|
||||||
|
expect(latest.request.length).toBe(32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BIP39 toggle swaps to generate_passphrase', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.getElementById('gen-kind-bip39') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls;
|
||||||
|
expect(calls.some(([msg]) => (msg as { type: string }).type === 'generate_passphrase')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use-this-value invokes onPicked with current preview and closes', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
const onPicked = vi.fn();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.querySelector('#gen-use') as HTMLButtonElement).click();
|
||||||
|
expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT');
|
||||||
|
expect(document.querySelector('.gen-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save-as-default sends update_vault_settings with the current request', async () => {
|
||||||
|
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
|
||||||
|
if (msg.type === 'generate_password') return { ok: true, data: { password: 'abc' } };
|
||||||
|
if (msg.type === 'get_vault_settings') {
|
||||||
|
return { ok: true, data: { settings: {
|
||||||
|
trash_retention: { kind: 'days', value: 30 },
|
||||||
|
field_history_retention: { kind: 'forever' },
|
||||||
|
generator_defaults: DEFAULT_REQ,
|
||||||
|
attachment_caps: {},
|
||||||
|
autofill_origin_acks: {},
|
||||||
|
} } };
|
||||||
|
}
|
||||||
|
if (msg.type === 'update_vault_settings') return { ok: true };
|
||||||
|
return { ok: false, error: 'unhandled' };
|
||||||
|
});
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.querySelector('#gen-save-default') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const updateCall = vi.mocked(sendMessage).mock.calls.find(
|
||||||
|
([m]) => (m as any).type === 'update_vault_settings',
|
||||||
|
);
|
||||||
|
expect(updateCall).toBeDefined();
|
||||||
|
const msg = updateCall![0] as { settings: { generator_defaults: GeneratorRequest } };
|
||||||
|
expect(msg.settings.generator_defaults.kind).toBe('random');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables use-button when no char class selected (Random)', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) {
|
||||||
|
const cb = document.getElementById(id) as HTMLInputElement;
|
||||||
|
cb.checked = false;
|
||||||
|
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
const useBtn = document.querySelector('#gen-use') as HTMLButtonElement;
|
||||||
|
expect(useBtn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeGeneratorPanel removes the DOM + handlers', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
closeGeneratorPanel();
|
||||||
|
expect(document.querySelector('.gen-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-expanded on the trigger when opened', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
closeGeneratorPanel();
|
||||||
|
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-generates a preview on open', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls.filter(
|
||||||
|
([msg]) => (msg as { type: string }).type === 'generate_password',
|
||||||
|
);
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape key closes the panel', async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(isGeneratorPanelOpen()).toBe(true);
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
expect(isGeneratorPanelOpen()).toBe(false);
|
||||||
|
expect(document.querySelector('.gen-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configure-defaults context renders only the save-default action (no use/cancel)", async () => {
|
||||||
|
const { parent, trigger } = setupMount();
|
||||||
|
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'configure-defaults' });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(document.querySelector('#gen-save-default')).not.toBeNull();
|
||||||
|
expect(document.querySelector('#gen-use')).toBeNull();
|
||||||
|
expect(document.querySelector('#gen-cancel')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
207
extension/src/popup/components/__tests__/sections-editor.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', async () => {
|
||||||
|
const escapeHtml = (s: string) => s
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
return { escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { renderSectionsEditor, generateFieldId, wireSectionsEditor } from '../fields';
|
||||||
|
import type { Section } from '../../../shared/types';
|
||||||
|
|
||||||
|
describe('generateFieldId', () => {
|
||||||
|
it('returns 16 hex chars', () => {
|
||||||
|
const id = generateFieldId();
|
||||||
|
expect(id).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
it('returns unique values on successive calls', () => {
|
||||||
|
const ids = new Set(Array.from({ length: 50 }, () => generateFieldId()));
|
||||||
|
expect(ids.size).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSectionsEditor', () => {
|
||||||
|
it('shows the disclosure toggle with the correct count', () => {
|
||||||
|
const sections: Section[] = [
|
||||||
|
{ name: 'a', fields: [
|
||||||
|
{ id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false },
|
||||||
|
{ id: 'f1', label: 'l', kind: 'password', value: { kind: 'password', value: 'p' }, hidden_by_default: true },
|
||||||
|
] },
|
||||||
|
{ fields: [
|
||||||
|
{ id: 'f2', label: 'l', kind: 'concealed', value: { kind: 'concealed', value: 'c' }, hidden_by_default: true },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
const html = renderSectionsEditor(sections, false);
|
||||||
|
expect(html).toContain('2 sections');
|
||||||
|
expect(html).toContain('3 fields');
|
||||||
|
expect(html).toContain('data-expanded="false"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows singular "1 section / 1 field" when applicable', () => {
|
||||||
|
const sections: Section[] = [
|
||||||
|
{ name: 'only', fields: [
|
||||||
|
{ id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false },
|
||||||
|
] },
|
||||||
|
];
|
||||||
|
const html = renderSectionsEditor(sections, false);
|
||||||
|
expect(html).toContain('1 section');
|
||||||
|
expect(html).toContain('1 field');
|
||||||
|
expect(html).not.toContain('1 sections');
|
||||||
|
expect(html).not.toContain('1 fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders expanded body when expanded=true', () => {
|
||||||
|
const html = renderSectionsEditor([], true);
|
||||||
|
expect(html).toContain('data-expanded="true"');
|
||||||
|
expect(html).toContain('add section');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wireSectionsEditor', () => {
|
||||||
|
it('toggle click flips data-expanded', () => {
|
||||||
|
document.body.innerHTML = renderSectionsEditor([], false);
|
||||||
|
const sections: Section[] = [];
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const toggle = document.querySelector('.disclosure__toggle') as HTMLButtonElement;
|
||||||
|
toggle.click();
|
||||||
|
const disclosure = document.querySelector('.disclosure') as HTMLElement;
|
||||||
|
expect(disclosure.getAttribute('data-expanded')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-section click appends an empty section', () => {
|
||||||
|
const sections: Section[] = [];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const addBtn = document.querySelector('.add-section') as HTMLButtonElement;
|
||||||
|
addBtn.click();
|
||||||
|
expect(sections).toHaveLength(1);
|
||||||
|
expect(sections[0]).toEqual({ name: undefined, fields: [] });
|
||||||
|
expect(rerender).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-text-field click on a section pushes a text field', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement;
|
||||||
|
addText.click();
|
||||||
|
expect(sections[0].fields).toHaveLength(1);
|
||||||
|
expect(sections[0].fields[0].kind).toBe('text');
|
||||||
|
expect(sections[0].fields[0].value.kind).toBe('text');
|
||||||
|
expect(sections[0].fields[0].value.value).toBe('');
|
||||||
|
expect(sections[0].fields[0].hidden_by_default).toBe(false);
|
||||||
|
expect(sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-password-field sets hidden_by_default=true', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
(document.querySelector('[data-add-field="password"][data-section-idx="0"]') as HTMLButtonElement).click();
|
||||||
|
expect(sections[0].fields[0].hidden_by_default).toBe(true);
|
||||||
|
expect(sections[0].fields[0].kind).toBe('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove-field button splices field', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [
|
||||||
|
{ id: 'f0', label: 'a', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false },
|
||||||
|
{ id: 'f1', label: 'b', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false },
|
||||||
|
] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
const deleteBtn = document.querySelector('[data-delete-field="f0"]') as HTMLButtonElement;
|
||||||
|
deleteBtn.click();
|
||||||
|
expect(sections[0].fields).toHaveLength(1);
|
||||||
|
expect(sections[0].fields[0].id).toBe('f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove-section button splices section (after confirm)', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const sections: Section[] = [
|
||||||
|
{ name: 'to-remove', fields: [] },
|
||||||
|
{ name: 'keep', fields: [] },
|
||||||
|
];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
(document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click();
|
||||||
|
expect(sections).toHaveLength(1);
|
||||||
|
expect(sections[0].name).toBe('keep');
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove-section cancelled confirm leaves section intact', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
const sections: Section[] = [{ name: 'stays', fields: [] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
(document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click();
|
||||||
|
expect(sections).toHaveLength(1);
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('label input change mutates section field label in place (no rerender)', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [
|
||||||
|
{ id: 'f0', label: 'old', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false },
|
||||||
|
] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
const rerender = vi.fn();
|
||||||
|
wireSectionsEditor(document.body, sections, rerender);
|
||||||
|
const labelInput = document.querySelector('[data-field-label="f0"]') as HTMLInputElement;
|
||||||
|
labelInput.value = 'new';
|
||||||
|
labelInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
expect(sections[0].fields[0].label).toBe('new');
|
||||||
|
expect(rerender).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('value input change mutates section field value in place', () => {
|
||||||
|
const sections: Section[] = [{ name: undefined, fields: [
|
||||||
|
{ id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'old' }, hidden_by_default: false },
|
||||||
|
] }];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
const valueInput = document.querySelector('[data-field-value-input="f0"]') as HTMLInputElement;
|
||||||
|
valueInput.value = 'new';
|
||||||
|
valueInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
expect(sections[0].fields[0].value).toEqual({ kind: 'text', value: 'new' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wireSectionsEditor preserves unsupported-kind fields on save', () => {
|
||||||
|
it('renders preserved note when section contains unsupported-kind fields', () => {
|
||||||
|
const sections: Section[] = [{
|
||||||
|
name: 'mixed',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000001', label: 'note', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'ok' }, hidden_by_default: false },
|
||||||
|
{ id: 'f0000002', label: 'when', kind: 'date' as any,
|
||||||
|
value: { kind: 'date', value: '2026-01-01' } as any, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
expect(document.body.innerHTML).toContain('1 field of unsupported kind');
|
||||||
|
expect(document.body.innerHTML).not.toContain('f0000002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add-text then save does not destroy unsupported-kind fields', () => {
|
||||||
|
const sections: Section[] = [{
|
||||||
|
name: 'mixed',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000002', label: 'when', kind: 'date' as any,
|
||||||
|
value: { kind: 'date', value: '2026-01-01' } as any, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
document.body.innerHTML = renderSectionsEditor(sections, true);
|
||||||
|
wireSectionsEditor(document.body, sections, vi.fn());
|
||||||
|
const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement;
|
||||||
|
addText.click();
|
||||||
|
expect(sections[0].fields).toHaveLength(2);
|
||||||
|
// Unsupported-kind field preserved untouched.
|
||||||
|
const dateField = sections[0].fields.find((f) => f.id === 'f0000002');
|
||||||
|
expect(dateField).toBeDefined();
|
||||||
|
expect(dateField!.value).toEqual({ kind: 'date', value: '2026-01-01' });
|
||||||
|
});
|
||||||
|
});
|
||||||
112
extension/src/popup/components/__tests__/sections-render.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', async () => {
|
||||||
|
const escapeHtml = (s: string) => s
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
return { escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { renderSections } from '../fields';
|
||||||
|
import type { Item } from '../../../shared/types';
|
||||||
|
|
||||||
|
function itemWithSections(sections: Item['sections']): Item {
|
||||||
|
return {
|
||||||
|
id: 'aaaaaaaaaaaaaaaa',
|
||||||
|
title: 'test',
|
||||||
|
type: 'login',
|
||||||
|
tags: [], favorite: false,
|
||||||
|
created: 0, modified: 0,
|
||||||
|
core: { type: 'login' },
|
||||||
|
sections,
|
||||||
|
attachments: [],
|
||||||
|
field_history: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderSections', () => {
|
||||||
|
it('returns empty string when item has no sections', () => {
|
||||||
|
const html = renderSections(itemWithSections([]), 'login');
|
||||||
|
expect(html).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips sections with zero fields', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{ name: 'empty', fields: [] },
|
||||||
|
]), 'login');
|
||||||
|
expect(html).not.toContain('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a named section header + field rows', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
name: 'recovery codes',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000001', label: 'code 1', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'abc-123' }, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('recovery codes');
|
||||||
|
expect(html).toContain('code 1');
|
||||||
|
expect(html).toContain('abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders concealed password fields with unique ids', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
name: 'backup',
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000002', label: 'pin', kind: 'password',
|
||||||
|
value: { kind: 'password', value: 'hunter2' }, hidden_by_default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('data-field-id="login-s0-f0"');
|
||||||
|
expect(html).toContain('data-revealed="false"');
|
||||||
|
expect(html).not.toMatch(/>hunter2</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders anonymous section with separator not header', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000003', label: 'extra', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'note' }, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('section-separator');
|
||||||
|
expect(html).not.toContain('section-header');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently skips unsupported field kinds', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000004', label: 'link', kind: 'url' as any,
|
||||||
|
value: { kind: 'url', value: 'https://example.com' } as any,
|
||||||
|
hidden_by_default: false },
|
||||||
|
{ id: 'f0000005', label: 'note', kind: 'text',
|
||||||
|
value: { kind: 'text', value: 'kept' }, hidden_by_default: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).not.toContain('https://example.com');
|
||||||
|
expect(html).toContain('kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders concealed fields for the concealed kind too', () => {
|
||||||
|
const html = renderSections(itemWithSections([
|
||||||
|
{
|
||||||
|
fields: [
|
||||||
|
{ id: 'f0000006', label: 'secret', kind: 'concealed',
|
||||||
|
value: { kind: 'concealed', value: 'shhh' }, hidden_by_default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]), 'login');
|
||||||
|
expect(html).toContain('data-field-id="login-s0-f0"');
|
||||||
|
expect(html).toContain('secret');
|
||||||
|
expect(html).not.toMatch(/>shhh</);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', async () => {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const setState = vi.fn();
|
||||||
|
const sendMessage = vi.fn();
|
||||||
|
const getState = vi.fn(() => ({
|
||||||
|
view: 'settings-vault',
|
||||||
|
entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
|
||||||
|
searchQuery: '', activeGroup: null, error: null, loading: false,
|
||||||
|
capturedTabId: null, capturedUrl: '', newType: null,
|
||||||
|
vaultSettings: {
|
||||||
|
trash_retention: { kind: 'days', value: 30 },
|
||||||
|
field_history_retention: { kind: 'forever' },
|
||||||
|
generator_defaults: {
|
||||||
|
kind: 'random', length: 20,
|
||||||
|
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: { kind: 'safe_only' },
|
||||||
|
},
|
||||||
|
attachment_caps: {},
|
||||||
|
autofill_origin_acks: { 'github.com': 1000000000, 'example.com': 999000000 },
|
||||||
|
},
|
||||||
|
generatorDefaults: null,
|
||||||
|
}));
|
||||||
|
const escapeHtml = (s: string) => s
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
return { navigate, setState, sendMessage, getState, escapeHtml, popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../generator-panel', () => ({
|
||||||
|
openGeneratorPanel: vi.fn(),
|
||||||
|
closeGeneratorPanel: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { renderVaultSettings } from '../settings-vault';
|
||||||
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
|
||||||
|
describe('settings-vault', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with seeded vault-settings values', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
expect(app.textContent).toContain('vault settings');
|
||||||
|
expect(app.textContent).toContain('github.com');
|
||||||
|
expect(app.textContent).toContain('example.com');
|
||||||
|
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
|
||||||
|
expect(trashSel.value).toBe('days:30');
|
||||||
|
const histSel = document.getElementById('history-retention') as HTMLSelectElement;
|
||||||
|
expect(histSel.value).toBe('forever');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders origin acks sorted by recency (descending)', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
const rows = Array.from(document.querySelectorAll('.ack-row__host')).map((e) => e.textContent);
|
||||||
|
expect(rows).toEqual(['github.com', 'example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save button disabled until a change is made', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
const saveBtn = document.getElementById('save-btn') as HTMLButtonElement;
|
||||||
|
expect(saveBtn.disabled).toBe(true);
|
||||||
|
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
|
||||||
|
trashSel.value = 'forever';
|
||||||
|
trashSel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
expect(saveBtn.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revoke button removes origin from pending and enables save', () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
(document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click();
|
||||||
|
expect(document.querySelector('[data-revoke="github.com"]')).toBeNull();
|
||||||
|
expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save button triggers update_vault_settings with pending', async () => {
|
||||||
|
const app = document.getElementById('app')!;
|
||||||
|
renderVaultSettings(app);
|
||||||
|
(document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click();
|
||||||
|
(document.getElementById('save-btn') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const call = vi.mocked(sendMessage).mock.calls.find(
|
||||||
|
([m]) => (m as any).type === 'update_vault_settings',
|
||||||
|
);
|
||||||
|
expect(call).toBeDefined();
|
||||||
|
const payload = call![0] as { settings: any };
|
||||||
|
expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com');
|
||||||
|
expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
extension/src/popup/components/__tests__/settings.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { renderSettings } from '../settings';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', () => ({
|
||||||
|
setState: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: vi.fn(() => false),
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
|
||||||
|
function settingsResponses() {
|
||||||
|
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({ ok: true, data: { settings: { captureEnabled: false, captureStyle: 'bar' } } })
|
||||||
|
.mockResolvedValueOnce({ ok: true, data: { blacklist: [] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('settings view', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a Sync now button', async () => {
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
expect(app.querySelector('#sync-now-btn')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
||||||
|
settingsResponses();
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#sync-now-btn')!.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({ type: 'sync' });
|
||||||
|
const status = app.querySelector('#sync-status')!;
|
||||||
|
expect(status.textContent).toMatch(/synced/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the error when sync fails', async () => {
|
||||||
|
settingsResponses();
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#sync-now-btn')!.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const status = app.querySelector('#sync-status')!;
|
||||||
|
expect(status.textContent).toMatch(/remote_unreachable/);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
extension/src/popup/components/__tests__/trash.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { renderTrash } from '../trash';
|
||||||
|
|
||||||
|
// Mock popup module
|
||||||
|
vi.mock('../../../shared/state', () => ({
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
vaultSettings: { trash_retention: { kind: 'days', value: 30 } },
|
||||||
|
})),
|
||||||
|
setState: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
popOutToTab: vi.fn(),
|
||||||
|
isInTab: vi.fn(() => false),
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sendMessage, navigate } from '../../../shared/state';
|
||||||
|
|
||||||
|
describe('trash view', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no trashed items', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { items: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderTrash(app);
|
||||||
|
|
||||||
|
expect(app.innerHTML).toContain('Trash is empty');
|
||||||
|
expect(app.querySelector('#empty-trash-btn')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders trashed items with restore buttons', async () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
['id1', { id: 'id1', type: 'login', title: 'Test Login', trashed_at: now - 3600, tags: [], favorite: false, modified: now, attachment_summaries: [] }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderTrash(app);
|
||||||
|
|
||||||
|
expect(app.innerHTML).toContain('Test Login');
|
||||||
|
expect(app.innerHTML).toContain('restore');
|
||||||
|
expect(app.querySelector('#empty-trash-btn')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('back button navigates to list', async () => {
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { items: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderTrash(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith('list');
|
||||||
|
});
|
||||||
|
});
|
||||||
207
extension/src/popup/components/attachments-disclosure.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/// Compact disclosure pattern for attachments — shared between all
|
||||||
|
/// item type forms (edit + view modes). Edit mode supports + attach
|
||||||
|
/// (uploads via SW) and × remove (defers blob delete until form save).
|
||||||
|
/// View mode supports ↓ download (decrypts via SW + browser download).
|
||||||
|
/// Image-mime rows lazy-load 16×16 thumbnails via object URLs;
|
||||||
|
/// teardownAttachmentsDisclosure() revokes them on view exit.
|
||||||
|
|
||||||
|
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||||
|
import type { AttachmentRef, VaultSettings } from '../../shared/types';
|
||||||
|
|
||||||
|
export type DisclosureMode = 'edit' | 'view';
|
||||||
|
|
||||||
|
export interface AttachmentsDisclosureOpts {
|
||||||
|
itemId: string;
|
||||||
|
attachments: AttachmentRef[];
|
||||||
|
mode: DisclosureMode;
|
||||||
|
onChange?: (next: AttachmentRef[]) => void; // edit mode only
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (n: number): string => {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
||||||
|
|
||||||
|
const objectUrlRegistry = new Map<string, string>(); // attachmentId → object URL
|
||||||
|
|
||||||
|
function teardownObjectUrls(): void {
|
||||||
|
for (const url of objectUrlRegistry.values()) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
objectUrlRegistry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchThumbUrl(itemId: string, attachmentId: string, mime: string): Promise<string | null> {
|
||||||
|
if (objectUrlRegistry.has(attachmentId)) return objectUrlRegistry.get(attachmentId)!;
|
||||||
|
const resp = await sendMessage({ type: 'download_attachment', itemId, attachmentId });
|
||||||
|
if (!resp || !resp.ok) return null;
|
||||||
|
const data = resp.data as { bytes: ArrayBuffer };
|
||||||
|
const blob = new Blob([data.bytes], { type: mime });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
objectUrlRegistry.set(attachmentId, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): string {
|
||||||
|
const count = opts.attachments.length;
|
||||||
|
const headerLabel = count === 0 ? 'attachments' : `attachments (${count})`;
|
||||||
|
const expanded = count > 0;
|
||||||
|
const rowsHtml = opts.attachments.map((a) => {
|
||||||
|
const action = opts.mode === 'edit' ? '×' : '↓';
|
||||||
|
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
|
||||||
|
const iconHtml = isImage(a.mime_type)
|
||||||
|
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>`
|
||||||
|
: `<span class="attachment-row__icon">📄</span>`;
|
||||||
|
return `
|
||||||
|
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
|
||||||
|
${iconHtml}
|
||||||
|
<span class="attachment-row__name">${escapeHtml(a.filename)}</span>
|
||||||
|
<span class="attachment-row__meta">${formatBytes(a.size)}</span>
|
||||||
|
<span class="${actionClass}" data-att-id="${escapeHtml(a.id)}">${action}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
const addBtn = opts.mode === 'edit'
|
||||||
|
? `<button class="attachment-add-btn" type="button">+ attach file</button>`
|
||||||
|
: '';
|
||||||
|
const fileInput = opts.mode === 'edit'
|
||||||
|
? `<input type="file" class="attachments-disclosure__file-input" hidden />`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<details class="attachments-disclosure" ${expanded ? 'open' : ''}>
|
||||||
|
<summary>${expanded ? '▾' : '▸'} ${headerLabel}</summary>
|
||||||
|
<div class="attachments-disclosure__body">
|
||||||
|
${rowsHtml}
|
||||||
|
${addBtn}
|
||||||
|
</div>
|
||||||
|
${fileInput}
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach event listeners to a disclosure already in the DOM (rendered
|
||||||
|
/// by `renderAttachmentsDisclosure`).
|
||||||
|
///
|
||||||
|
/// **Contract: call once per DOM instance.** Each invocation adds new
|
||||||
|
/// listeners; calling this twice on the same `<details>` element will
|
||||||
|
/// fire onChange twice per click. The standard re-render pattern is:
|
||||||
|
/// 1. Replace `disc.outerHTML` with a fresh `renderAttachmentsDisclosure(...)` call
|
||||||
|
/// 2. Then call `wireAttachmentsDisclosure(...)` to attach handlers to the NEW DOM
|
||||||
|
/// (Old listeners are GC'd when the previous `<details>` element is detached.)
|
||||||
|
export function wireAttachmentsDisclosure(
|
||||||
|
root: HTMLElement,
|
||||||
|
opts: AttachmentsDisclosureOpts,
|
||||||
|
): void {
|
||||||
|
const disc = root.querySelector('.attachments-disclosure') as HTMLDetailsElement | null;
|
||||||
|
if (!disc) return;
|
||||||
|
|
||||||
|
// Lazy-load image thumbs whenever disclosure opens.
|
||||||
|
const loadThumbs = async (): Promise<void> => {
|
||||||
|
const thumbs = disc.querySelectorAll<HTMLElement>('.attachment-row__thumb');
|
||||||
|
for (const thumb of thumbs) {
|
||||||
|
const attId = thumb.dataset.attId;
|
||||||
|
const mime = thumb.dataset.mime;
|
||||||
|
if (!attId || !mime) continue;
|
||||||
|
const url = await fetchThumbUrl(opts.itemId, attId, mime);
|
||||||
|
if (url) {
|
||||||
|
thumb.innerHTML = `<img src="${url}" alt="" />`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (disc.open) loadThumbs().catch(() => { /* swallow: thumb failures are non-fatal */ });
|
||||||
|
disc.addEventListener('toggle', () => {
|
||||||
|
if (disc.open) loadThumbs().catch(() => { /* swallow: thumb failures are non-fatal */ });
|
||||||
|
else teardownObjectUrls();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit mode: + attach file
|
||||||
|
if (opts.mode === 'edit') {
|
||||||
|
const fileInput = disc.querySelector('.attachments-disclosure__file-input') as HTMLInputElement | null;
|
||||||
|
const addBtn = disc.querySelector('.attachment-add-btn') as HTMLButtonElement | null;
|
||||||
|
addBtn?.addEventListener('click', () => fileInput?.click());
|
||||||
|
|
||||||
|
fileInput?.addEventListener('change', async () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Cap enforcement (popup-side, before sending to SW).
|
||||||
|
const settingsResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (settingsResp && settingsResp.ok) {
|
||||||
|
const settings = (settingsResp.data as { settings: VaultSettings }).settings;
|
||||||
|
const caps = settings.attachment_caps;
|
||||||
|
if (caps?.per_attachment_max_bytes && file.size > caps.per_attachment_max_bytes) {
|
||||||
|
alert(`file too large (${formatBytes(file.size)} / cap ${formatBytes(caps.per_attachment_max_bytes)})`);
|
||||||
|
fileInput.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (caps?.per_item_max_count && opts.attachments.length + 1 > caps.per_item_max_count) {
|
||||||
|
alert(`item attachment count would exceed cap (${opts.attachments.length + 1} / ${caps.per_item_max_count})`);
|
||||||
|
fileInput.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const resp = await sendMessage({
|
||||||
|
type: 'upload_attachment',
|
||||||
|
itemId: opts.itemId,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
const data = resp.data as { attachment: AttachmentRef };
|
||||||
|
opts.onChange?.([...opts.attachments, data.attachment]);
|
||||||
|
} else {
|
||||||
|
alert(`upload failed: ${resp?.error ?? 'service worker unavailable'}`);
|
||||||
|
}
|
||||||
|
fileInput.value = ''; // allow re-pick of same file later
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove (×) buttons — defer the actual blob delete until form save
|
||||||
|
disc.querySelectorAll<HTMLElement>('.attachment-row__remove').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const attId = btn.dataset.attId;
|
||||||
|
if (!attId) return;
|
||||||
|
opts.onChange?.(opts.attachments.filter((a) => a.id !== attId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// View mode: ↓ download
|
||||||
|
if (opts.mode === 'view') {
|
||||||
|
disc.querySelectorAll<HTMLElement>('.attachment-row__download').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const attId = btn.dataset.attId;
|
||||||
|
if (!attId) return;
|
||||||
|
const att = opts.attachments.find((a) => a.id === attId);
|
||||||
|
if (!att) return;
|
||||||
|
const resp = await sendMessage({ type: 'download_attachment', itemId: opts.itemId, attachmentId: attId });
|
||||||
|
if (!resp || !resp.ok) {
|
||||||
|
alert(`download failed: ${resp?.error ?? 'service worker unavailable'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = resp.data as { bytes: ArrayBuffer; filename: string; mimeType: string };
|
||||||
|
const blob = new Blob([data.bytes], { type: data.mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = data.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call from the parent component's teardown to release any image thumbs.
|
||||||
|
export function teardownAttachmentsDisclosure(): void {
|
||||||
|
teardownObjectUrls();
|
||||||
|
}
|
||||||
138
extension/src/popup/components/devices.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/// Device management view — list devices with revoke actions.
|
||||||
|
|
||||||
|
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
|
import type { Device } from '../../shared/types';
|
||||||
|
|
||||||
|
function relativeTime(unixSec: number): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = now - unixSec;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectDefaultDeviceName(): string {
|
||||||
|
const ua = navigator.userAgent ?? '';
|
||||||
|
const platform = (navigator.platform ?? '').toLowerCase();
|
||||||
|
const isFirefox = /firefox/i.test(ua);
|
||||||
|
const isEdge = /edg/i.test(ua);
|
||||||
|
const isChrome = /chrome/i.test(ua) && !isEdge;
|
||||||
|
const browser = isFirefox ? 'Firefox' : isEdge ? 'Edge' : isChrome ? 'Chrome' : 'Browser';
|
||||||
|
const os = platform.includes('mac') ? 'macOS'
|
||||||
|
: platform.includes('win') ? 'Windows'
|
||||||
|
: platform.includes('linux') ? 'Linux'
|
||||||
|
: 'Unknown';
|
||||||
|
return `${browser} on ${os}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
// No cleanup needed
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDevices(app: HTMLElement): Promise<void> {
|
||||||
|
// Get current device name from local storage
|
||||||
|
const stored = await chrome.storage.local.get(['device_name']);
|
||||||
|
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
||||||
|
|
||||||
|
// Fetch device list
|
||||||
|
const resp = await sendMessage({ type: 'list_devices' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = (resp.data as { devices: Device[] }).devices;
|
||||||
|
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="devices-header">
|
||||||
|
<button class="btn" id="back-btn">← back</button>
|
||||||
|
<h3 style="margin:0;">devices</h3>
|
||||||
|
</div>
|
||||||
|
${!isRegistered ? `
|
||||||
|
<div class="device-banner">
|
||||||
|
<span>⚠ This device is not registered</span>
|
||||||
|
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${devices.length === 0
|
||||||
|
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||||||
|
: devices.map((d) => {
|
||||||
|
const isCurrentDevice = d.name === currentDeviceName;
|
||||||
|
return `
|
||||||
|
<div class="device-row">
|
||||||
|
<div class="device-row__info">
|
||||||
|
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
|
||||||
|
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
|
||||||
|
</div>
|
||||||
|
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire handlers
|
||||||
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
|
|
||||||
|
document.getElementById('register-btn')?.addEventListener('click', () => {
|
||||||
|
const banner = document.querySelector('.device-banner');
|
||||||
|
if (!banner) return;
|
||||||
|
const defaultName = detectDefaultDeviceName();
|
||||||
|
banner.innerHTML = `
|
||||||
|
<label class="label" for="register-name-input" style="display:block;margin-bottom:4px;">
|
||||||
|
Name this device
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="register-name-input"
|
||||||
|
type="text"
|
||||||
|
value="${escapeHtml(defaultName)}"
|
||||||
|
style="width:100%;margin-bottom:8px;"
|
||||||
|
>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="btn btn-primary" id="register-confirm-btn">Register</button>
|
||||||
|
<button class="btn" id="register-cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('register-cancel-btn')?.addEventListener('click', () => {
|
||||||
|
renderDevices(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('register-confirm-btn')?.addEventListener('click', async () => {
|
||||||
|
const input = document.getElementById('register-name-input') as HTMLInputElement | null;
|
||||||
|
const name = input?.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
setState({ error: 'Device name is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await sendMessage({ type: 'register_this_device', name });
|
||||||
|
if (result.ok) {
|
||||||
|
renderDevices(app);
|
||||||
|
} else {
|
||||||
|
setState({ error: result.error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const name = btn.dataset.revoke;
|
||||||
|
if (!name) return;
|
||||||
|
if (!confirm(`Revoke ${name}? This device will no longer be authorized.`)) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '...';
|
||||||
|
const result = await sendMessage({ type: 'revoke_device', name });
|
||||||
|
if (result.ok) {
|
||||||
|
await sendMessage({ type: 'sync' });
|
||||||
|
renderDevices(app);
|
||||||
|
} else {
|
||||||
|
setState({ error: result.error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
135
extension/src/popup/components/field-history.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/// Field history view — shows password/concealed field history for an item.
|
||||||
|
|
||||||
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
|
import type { FieldHistoryView } from '../../shared/types';
|
||||||
|
|
||||||
|
function relativeTime(unixSec: number): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = now - unixSec;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
|
||||||
|
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealedSet = new Set<string>();
|
||||||
|
|
||||||
|
// Map from entry key → plaintext value; populated on each render so we never
|
||||||
|
// embed the secret in the DOM (no data-copy attribute holds the raw secret).
|
||||||
|
const valueStore = new Map<string, string>();
|
||||||
|
|
||||||
|
export function teardown(): void {
|
||||||
|
revealedSet.clear();
|
||||||
|
valueStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
||||||
|
const state = getState();
|
||||||
|
const itemId = state.historyItemId;
|
||||||
|
const item = state.selectedItem;
|
||||||
|
|
||||||
|
if (!itemId || !item) {
|
||||||
|
navigate('list');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch field history
|
||||||
|
const resp = await sendMessage({ type: 'get_field_history', id: itemId });
|
||||||
|
if (!resp.ok) {
|
||||||
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load history</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = (resp.data as { history: FieldHistoryView[] }).history;
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="history-header">
|
||||||
|
<button class="btn" id="back-btn">← back to item</button>
|
||||||
|
<h3 style="margin:0;">password history</h3>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="text-align:center;margin-top:32px;">No history available</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the value store for this render pass
|
||||||
|
valueStore.clear();
|
||||||
|
|
||||||
|
function renderEntry(fieldId: string, value: string, timestamp: number, isCurrent: boolean): string {
|
||||||
|
const entryKey = `${fieldId}-${timestamp}`;
|
||||||
|
const isRevealed = revealedSet.has(entryKey);
|
||||||
|
const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••';
|
||||||
|
valueStore.set(entryKey, value);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
|
||||||
|
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
||||||
|
<div class="history-entry__meta">
|
||||||
|
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
||||||
|
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">📋</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
for (const field of history) {
|
||||||
|
if (history.length > 1) {
|
||||||
|
content += `<div class="history-field-label">${escapeHtml(field.field_name)}</div>`;
|
||||||
|
}
|
||||||
|
// Current value first
|
||||||
|
content += renderEntry(field.field_id, field.current_value, item.modified, true);
|
||||||
|
// Historical values
|
||||||
|
for (const entry of field.entries) {
|
||||||
|
content += renderEntry(field.field_id, entry.value, entry.changed_at, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="history-header">
|
||||||
|
<button class="btn" id="back-btn">← back to item</button>
|
||||||
|
<h3 style="margin:0;">password history</h3>
|
||||||
|
</div>
|
||||||
|
<div class="history-item-title">${escapeHtml(item.title)}</div>
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire handlers
|
||||||
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||||
|
|
||||||
|
// Toggle reveal on click
|
||||||
|
app.querySelectorAll<HTMLElement>('.history-entry').forEach((el) => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('history-entry__copy')) return;
|
||||||
|
const key = el.dataset.entry;
|
||||||
|
if (!key) return;
|
||||||
|
if (revealedSet.has(key)) {
|
||||||
|
revealedSet.delete(key);
|
||||||
|
} else {
|
||||||
|
revealedSet.add(key);
|
||||||
|
}
|
||||||
|
renderFieldHistory(app);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy buttons
|
||||||
|
app.querySelectorAll<HTMLButtonElement>('[data-entry-copy]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const key = btn.dataset.entryCopy ?? '';
|
||||||
|
const value = valueStore.get(key) ?? '';
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
btn.textContent = '✓';
|
||||||
|
setTimeout(() => { btn.textContent = '📋'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
|
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
|
||||||
/// copy click handlers on any rendered rows.
|
/// copy click handlers on any rendered rows.
|
||||||
|
|
||||||
import { escapeHtml } from '../popup';
|
import { escapeHtml } from '../../shared/state';
|
||||||
|
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
||||||
|
|
||||||
export interface RowOpts {
|
export interface RowOpts {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -69,14 +70,14 @@ export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SignatureBlockOpts {
|
export interface SignatureBlockOpts {
|
||||||
accent?: 'blue' | 'green' | 'amber' | 'red';
|
accent?: 'gold' | 'green' | 'amber' | 'red';
|
||||||
children: string;
|
children: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Container for the type-specific signature panel. `children` is HTML
|
/// Container for the type-specific signature panel. `children` is HTML
|
||||||
/// the caller has already produced (and escaped where needed).
|
/// the caller has already produced (and escaped where needed).
|
||||||
export function renderSignatureBlock(opts: SignatureBlockOpts): string {
|
export function renderSignatureBlock(opts: SignatureBlockOpts): string {
|
||||||
const accent = opts.accent ?? 'blue';
|
const accent = opts.accent ?? 'gold';
|
||||||
return `
|
return `
|
||||||
<div class="sig-block sig-block--${accent}">${opts.children}</div>
|
<div class="sig-block sig-block--${accent}">${opts.children}</div>
|
||||||
`;
|
`;
|
||||||
@@ -117,3 +118,237 @@ export function wireFieldHandlers(scope: HTMLElement): void {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render an Item's sections as read-only field rows. Each section with
|
||||||
|
/// ≥1 field emits a header (if named) or thin separator (if anonymous)
|
||||||
|
/// plus field rows via renderRow / renderConcealedRow. Sections with
|
||||||
|
/// 0 fields are skipped. Fields with unsupported kinds are silently
|
||||||
|
/// skipped (β₂ supports text, password, concealed only).
|
||||||
|
///
|
||||||
|
/// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`)
|
||||||
|
/// so multiple typed-item detail views rendered in sequence don't
|
||||||
|
/// collide on wireFieldHandlers lookups.
|
||||||
|
export function renderSections(item: Item, idPrefix: string): string {
|
||||||
|
let out = '';
|
||||||
|
item.sections.forEach((section, sIdx) => {
|
||||||
|
const visibleFields = section.fields.filter(
|
||||||
|
(f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed',
|
||||||
|
);
|
||||||
|
if (visibleFields.length === 0) return;
|
||||||
|
|
||||||
|
if (section.name) {
|
||||||
|
out += `<div class="section-header">${escapeHtml(section.name)}</div>`;
|
||||||
|
} else {
|
||||||
|
out += `<hr class="section-separator">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleFields.forEach((field, fIdx) => {
|
||||||
|
if (field.value.kind === 'text') {
|
||||||
|
out += renderRow({ label: field.label, value: field.value.value, copyable: true });
|
||||||
|
} else if (field.value.kind === 'password' || field.value.kind === 'concealed') {
|
||||||
|
out += renderConcealedRow({
|
||||||
|
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
||||||
|
label: field.label,
|
||||||
|
value: field.value.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16-char hex FieldId. crypto.getRandomValues for 8 bytes.
|
||||||
|
export function generateFieldId(): string {
|
||||||
|
const bytes = new Uint8Array(8);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeField(kind: 'text' | 'password' | 'concealed'): Field {
|
||||||
|
const value: FieldValue = { kind, value: '' };
|
||||||
|
return {
|
||||||
|
id: generateFieldId(),
|
||||||
|
label: 'new field',
|
||||||
|
kind,
|
||||||
|
value,
|
||||||
|
hidden_by_default: kind !== 'text',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the collapsible custom-sections editor. Returns HTML for the
|
||||||
|
/// disclosure toggle + body. The expanded-state is owned externally
|
||||||
|
/// (via a module-scope flag in the caller); this helper reads it as
|
||||||
|
/// the `expanded` parameter.
|
||||||
|
export function renderSectionsEditor(sections: Section[], expanded: boolean): string {
|
||||||
|
const sectionCount = sections.length;
|
||||||
|
const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0);
|
||||||
|
const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`;
|
||||||
|
const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`;
|
||||||
|
const summary = sectionCount === 0 && fieldCount === 0
|
||||||
|
? 'no custom fields'
|
||||||
|
: `${sectionLabel}, ${fieldLabel}`;
|
||||||
|
|
||||||
|
const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="disclosure" data-expanded="${expanded ? 'true' : 'false'}">
|
||||||
|
<button type="button" class="disclosure__toggle">▾ custom sections & fields (${escapeHtml(summary)})</button>
|
||||||
|
<div class="disclosure__body">
|
||||||
|
${body}
|
||||||
|
<button type="button" class="add-section">+ add section</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSectionBlock(section: Section, sIdx: number): string {
|
||||||
|
const nameDisplay = section.name
|
||||||
|
? `<span class="name">${escapeHtml(section.name)}</span>`
|
||||||
|
: `<span class="name anon">(anonymous)</span>`;
|
||||||
|
|
||||||
|
// Only render supported kinds. Other-kind fields stay in sectionsDraft
|
||||||
|
// untouched so they survive save intact.
|
||||||
|
const editable = section.fields.filter(
|
||||||
|
(f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed',
|
||||||
|
);
|
||||||
|
const fieldsHtml = editable.map((f) => renderEditorField(f, sIdx, 0)).join('');
|
||||||
|
|
||||||
|
const preservedCount = section.fields.length - editable.length;
|
||||||
|
const preservedNote = preservedCount > 0
|
||||||
|
? `<div class="section-editor__preserved">${preservedCount} field${preservedCount === 1 ? '' : 's'} of unsupported kind (edit via CLI)</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="section-editor" data-section-idx="${sIdx}">
|
||||||
|
<div class="section-editor__head">
|
||||||
|
${nameDisplay}
|
||||||
|
<span class="actions">
|
||||||
|
<button type="button" data-rename-section="${sIdx}">rename</button>
|
||||||
|
<button type="button" data-remove-section="${sIdx}">× remove section</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${fieldsHtml}
|
||||||
|
${preservedNote}
|
||||||
|
<div class="section-editor__add">
|
||||||
|
<button type="button" data-add-field="text" data-section-idx="${sIdx}">+ text</button>
|
||||||
|
<button type="button" data-add-field="password" data-section-idx="${sIdx}">+ password</button>
|
||||||
|
<button type="button" data-add-field="concealed" data-section-idx="${sIdx}">+ concealed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorField(field: Field, sIdx: number, _fIdx: number): string {
|
||||||
|
const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed')
|
||||||
|
? field.value.value
|
||||||
|
: '';
|
||||||
|
const inputType = field.value.kind === 'text' ? 'text' : 'password';
|
||||||
|
return `
|
||||||
|
<div class="section-editor__field">
|
||||||
|
<input type="text" data-field-label="${escapeHtml(field.id)}" value="${escapeHtml(field.label)}" placeholder="label">
|
||||||
|
<input type="${inputType}" data-field-value-input="${escapeHtml(field.id)}" value="${escapeHtml(valueStr)}" placeholder="value">
|
||||||
|
<button type="button" class="delete-field" data-delete-field="${escapeHtml(field.id)}" data-section-idx="${sIdx}">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findField(
|
||||||
|
sectionsDraft: Section[],
|
||||||
|
fieldId: string,
|
||||||
|
): { section: Section; fieldIdx: number } | null {
|
||||||
|
for (const section of sectionsDraft) {
|
||||||
|
const idx = section.fields.findIndex((f) => f.id === fieldId);
|
||||||
|
if (idx >= 0) return { section, fieldIdx: idx };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire click + input handlers on a rendered sections-editor. Mutations
|
||||||
|
/// happen in place on `sectionsDraft`. `rerender` is called after any
|
||||||
|
/// structural change (add/remove) to regenerate the disclosure body;
|
||||||
|
/// label/value edits do NOT trigger rerender (would steal focus).
|
||||||
|
export function wireSectionsEditor(
|
||||||
|
scope: HTMLElement,
|
||||||
|
sectionsDraft: Section[],
|
||||||
|
rerender: () => void,
|
||||||
|
): void {
|
||||||
|
const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null;
|
||||||
|
toggle?.addEventListener('click', () => {
|
||||||
|
const disclosure = scope.querySelector('.disclosure') as HTMLElement | null;
|
||||||
|
if (!disclosure) return;
|
||||||
|
const expanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
|
disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelector('.add-section')?.addEventListener('click', () => {
|
||||||
|
sectionsDraft.push({ name: undefined, fields: [] });
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-rename-section]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sIdx = Number(btn.dataset.renameSection);
|
||||||
|
const current = sectionsDraft[sIdx]?.name ?? '';
|
||||||
|
const name = window.prompt('Section name (empty for none):', current);
|
||||||
|
if (name === null) return;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
sectionsDraft[sIdx].name = trimmed || undefined;
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-remove-section]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sIdx = Number(btn.dataset.removeSection);
|
||||||
|
const name = sectionsDraft[sIdx]?.name ?? '(anonymous)';
|
||||||
|
if (!window.confirm(`Remove section "${name}" and all its fields?`)) return;
|
||||||
|
sectionsDraft.splice(sIdx, 1);
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-add-field]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const sIdx = Number(btn.dataset.sectionIdx);
|
||||||
|
const kind = btn.dataset.addField as 'text' | 'password' | 'concealed';
|
||||||
|
sectionsDraft[sIdx].fields.push(makeField(kind));
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-delete-field]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const fieldId = btn.dataset.deleteField ?? '';
|
||||||
|
const found = findField(sectionsDraft, fieldId);
|
||||||
|
if (!found) return;
|
||||||
|
found.section.fields = found.section.fields.filter((f) => f.id !== fieldId);
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLInputElement>('[data-field-label]').forEach((input) => {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const fieldId = input.dataset.fieldLabel ?? '';
|
||||||
|
const found = findField(sectionsDraft, fieldId);
|
||||||
|
if (found) {
|
||||||
|
found.section.fields[found.fieldIdx].label = input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLInputElement>('[data-field-value-input]').forEach((input) => {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const fieldId = input.dataset.fieldValueInput ?? '';
|
||||||
|
const found = findField(sectionsDraft, fieldId);
|
||||||
|
if (!found) return;
|
||||||
|
const field = found.section.fields[found.fieldIdx];
|
||||||
|
// Only mutate supported kinds. Unsupported kinds are never rendered
|
||||||
|
// as editable (filtered by renderSectionBlock), so this path shouldn't
|
||||||
|
// fire for them — but guard defensively.
|
||||||
|
if (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') {
|
||||||
|
const kind = field.value.kind;
|
||||||
|
field.value = { kind, value: input.value };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
24
extension/src/popup/components/form-header.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/// Shared header chrome for typed form views (login, secure-note, identity, card,
|
||||||
|
/// key, totp, document). Renders the title row plus a fullscreen-only "esc to
|
||||||
|
/// cancel" subtitle. Use the existing `${...}` template-literal interpolation
|
||||||
|
/// at call sites: `${renderFormHeader({ titleText: 'new login' })}`.
|
||||||
|
///
|
||||||
|
/// item-form.ts (the type-selection screen) uses a different header structure
|
||||||
|
/// and does NOT consume this helper.
|
||||||
|
|
||||||
|
import { isInTab } from '../../shared/state';
|
||||||
|
|
||||||
|
export interface FormHeaderOpts {
|
||||||
|
titleText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFormHeader(opts: FormHeaderOpts): string {
|
||||||
|
return `
|
||||||
|
<div class="form-header">
|
||||||
|
<div class="detail-title">${opts.titleText}</div>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
|
||||||
|
</div>
|
||||||
|
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
348
extension/src/popup/components/generator-panel.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/// Inline generator panel — mounts inside a parent element (form root or
|
||||||
|
/// settings section). Trigger button gets aria-expanded toggled. Preview
|
||||||
|
/// updates live as knobs change (150ms debounce). Kind toggle swaps
|
||||||
|
/// between Random + BIP39 knob sets. Action row varies by context:
|
||||||
|
/// fill-field shows cancel+use; configure-defaults shows only save-default.
|
||||||
|
|
||||||
|
import { sendMessage } from '../../shared/state';
|
||||||
|
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
||||||
|
|
||||||
|
interface UiKnobs {
|
||||||
|
kind: 'random' | 'bip39';
|
||||||
|
// Random
|
||||||
|
length: number;
|
||||||
|
lower: boolean;
|
||||||
|
upper: boolean;
|
||||||
|
digits: boolean;
|
||||||
|
symbols: boolean;
|
||||||
|
symbolCharset: 'safe_only' | 'extended' | 'custom';
|
||||||
|
customSymbols: string;
|
||||||
|
// BIP39
|
||||||
|
wordCount: number;
|
||||||
|
separator: string;
|
||||||
|
capitalization: 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function knobsFromRequest(req: GeneratorRequest): UiKnobs {
|
||||||
|
const defaults: UiKnobs = {
|
||||||
|
kind: 'random',
|
||||||
|
length: 20, lower: true, upper: true, digits: true, symbols: true,
|
||||||
|
symbolCharset: 'safe_only', customSymbols: '',
|
||||||
|
wordCount: 5, separator: ' ', capitalization: 'lower',
|
||||||
|
};
|
||||||
|
if (req.kind === 'random') {
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
kind: 'random',
|
||||||
|
length: req.length,
|
||||||
|
lower: req.classes.lower,
|
||||||
|
upper: req.classes.upper,
|
||||||
|
digits: req.classes.digits,
|
||||||
|
symbols: req.classes.symbols,
|
||||||
|
symbolCharset: req.symbol_charset.kind,
|
||||||
|
customSymbols: req.symbol_charset.kind === 'custom' ? req.symbol_charset.value : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
kind: 'bip39',
|
||||||
|
wordCount: req.word_count,
|
||||||
|
separator: req.separator,
|
||||||
|
capitalization: req.capitalization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFromKnobs(knobs: UiKnobs): GeneratorRequest {
|
||||||
|
if (knobs.kind === 'random') {
|
||||||
|
return {
|
||||||
|
kind: 'random',
|
||||||
|
length: knobs.length,
|
||||||
|
classes: {
|
||||||
|
lower: knobs.lower, upper: knobs.upper,
|
||||||
|
digits: knobs.digits, symbols: knobs.symbols,
|
||||||
|
},
|
||||||
|
symbol_charset:
|
||||||
|
knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } :
|
||||||
|
knobs.symbolCharset === 'extended' ? { kind: 'extended' } :
|
||||||
|
{ kind: 'custom', value: knobs.customSymbols },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'bip39',
|
||||||
|
word_count: knobs.wordCount,
|
||||||
|
separator: knobs.separator,
|
||||||
|
capitalization: knobs.capitalization,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeneratorPanelContext = 'fill-field' | 'configure-defaults';
|
||||||
|
|
||||||
|
export interface OpenPanelOpts {
|
||||||
|
parent: HTMLElement; // mount target (form root or settings section)
|
||||||
|
trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here)
|
||||||
|
initial: GeneratorRequest;
|
||||||
|
context: GeneratorPanelContext;
|
||||||
|
onPicked?: (value: string) => void; // required when context === 'fill-field'
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePanel: {
|
||||||
|
host: HTMLElement;
|
||||||
|
trigger: HTMLElement;
|
||||||
|
cleanup: () => void;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function openGeneratorPanel(opts: OpenPanelOpts): void {
|
||||||
|
closeGeneratorPanel();
|
||||||
|
|
||||||
|
const knobs = knobsFromRequest(opts.initial);
|
||||||
|
let currentPreview = '';
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'gen-panel';
|
||||||
|
opts.parent.appendChild(host);
|
||||||
|
|
||||||
|
opts.trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
const escHandler = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeGeneratorPanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
if (debounceTimer !== null) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = null;
|
||||||
|
}
|
||||||
|
opts.trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
host.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
activePanel = { host, trigger: opts.trigger, cleanup };
|
||||||
|
|
||||||
|
const refreshPreview = (): void => {
|
||||||
|
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
debounceTimer = null;
|
||||||
|
const request = requestFromKnobs(knobs);
|
||||||
|
const msg = knobs.kind === 'random'
|
||||||
|
? { type: 'generate_password' as const, request }
|
||||||
|
: { type: 'generate_passphrase' as const, request };
|
||||||
|
const resp = await sendMessage(msg);
|
||||||
|
if (resp.ok) {
|
||||||
|
const d = resp.data as { password?: string; passphrase?: string };
|
||||||
|
currentPreview = d.password ?? d.passphrase ?? '';
|
||||||
|
const el = host.querySelector('.preview__value');
|
||||||
|
if (el) el.textContent = currentPreview;
|
||||||
|
updateValidation();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateValidation = (): void => {
|
||||||
|
const useBtn = host.querySelector('#gen-use') as HTMLButtonElement | null;
|
||||||
|
if (!useBtn) return;
|
||||||
|
const noClass = knobs.kind === 'random'
|
||||||
|
&& !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols);
|
||||||
|
useBtn.disabled = noClass;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wireInner = (): void => {
|
||||||
|
host.querySelector('#gen-kind-random')?.addEventListener('click', () => {
|
||||||
|
knobs.kind = 'random'; render();
|
||||||
|
});
|
||||||
|
host.querySelector('#gen-kind-bip39')?.addEventListener('click', () => {
|
||||||
|
knobs.kind = 'bip39'; render();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-length')?.addEventListener('input', (e) => {
|
||||||
|
knobs.length = Number((e.target as HTMLInputElement).value);
|
||||||
|
const out = host.querySelector('#gen-length-val');
|
||||||
|
if (out) out.textContent = String(knobs.length);
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { id, key } of [
|
||||||
|
{ id: 'gen-lower', key: 'lower' as const },
|
||||||
|
{ id: 'gen-upper', key: 'upper' as const },
|
||||||
|
{ id: 'gen-digits', key: 'digits' as const },
|
||||||
|
{ id: 'gen-symbols', key: 'symbols' as const },
|
||||||
|
]) {
|
||||||
|
host.querySelector(`#${id}`)?.addEventListener('change', (e) => {
|
||||||
|
knobs[key] = (e.target as HTMLInputElement).checked;
|
||||||
|
updateValidation();
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[data-symbol-charset]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.symbolCharset = btn.dataset.symbolCharset as UiKnobs['symbolCharset'];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-word-count')?.addEventListener('input', (e) => {
|
||||||
|
knobs.wordCount = Number((e.target as HTMLInputElement).value);
|
||||||
|
const out = host.querySelector('#gen-word-count-val');
|
||||||
|
if (out) out.textContent = String(knobs.wordCount);
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[data-separator]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.separator = btn.dataset.separator ?? ' ';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[data-capitalization]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization'];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('.preview__regen')?.addEventListener('click', () => {
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-use')?.addEventListener('click', () => {
|
||||||
|
opts.onPicked?.(currentPreview);
|
||||||
|
closeGeneratorPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-cancel')?.addEventListener('click', () => {
|
||||||
|
closeGeneratorPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-save-default')?.addEventListener('click', async () => {
|
||||||
|
const link = host.querySelector('#gen-save-default') as HTMLElement | null;
|
||||||
|
const settingsResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (!settingsResp.ok) return;
|
||||||
|
const settings = (settingsResp.data as { settings: VaultSettings }).settings;
|
||||||
|
settings.generator_defaults = requestFromKnobs(knobs);
|
||||||
|
const updateResp = await sendMessage({ type: 'update_vault_settings', settings });
|
||||||
|
if (!updateResp.ok) return;
|
||||||
|
if (link) {
|
||||||
|
link.querySelector('.save-link__toast')?.remove();
|
||||||
|
const toast = document.createElement('span');
|
||||||
|
toast.className = 'save-link__toast';
|
||||||
|
toast.textContent = '✓ saved';
|
||||||
|
link.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (): void => {
|
||||||
|
host.innerHTML = buildInnerHtml(knobs, opts.context);
|
||||||
|
wireInner();
|
||||||
|
refreshPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGeneratorPanel(): void {
|
||||||
|
if (activePanel === null) return;
|
||||||
|
activePanel.cleanup();
|
||||||
|
activePanel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGeneratorPanelOpen(): boolean {
|
||||||
|
return activePanel !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML builders ---
|
||||||
|
|
||||||
|
function buildInnerHtml(knobs: UiKnobs, context: GeneratorPanelContext): string {
|
||||||
|
const actionRow = context === 'fill-field'
|
||||||
|
? `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>
|
||||||
|
<button class="btn" id="gen-cancel" type="button">cancel</button>
|
||||||
|
<button class="btn btn-primary" id="gen-use" type="button">use</button>`
|
||||||
|
: `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="panel-toggle">
|
||||||
|
<button id="gen-kind-random" type="button" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
|
||||||
|
<button id="gen-kind-bip39" type="button" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
|
||||||
|
</div>
|
||||||
|
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
|
||||||
|
<div class="preview">
|
||||||
|
<span class="preview__value"></span>
|
||||||
|
<button type="button" class="preview__regen" title="regenerate">↻</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
${actionRow}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRandomKnobs(k: UiKnobs): string {
|
||||||
|
return `
|
||||||
|
<div class="knob">
|
||||||
|
<span class="knob__label">length</span>
|
||||||
|
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="knob__slider">
|
||||||
|
<span class="knob__value" id="gen-length-val">${k.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="classes">
|
||||||
|
<label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label>
|
||||||
|
<label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label>
|
||||||
|
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
|
||||||
|
<label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
|
||||||
|
</div>
|
||||||
|
<details class="more">
|
||||||
|
<summary>more ▾</summary>
|
||||||
|
<div class="more__advanced">
|
||||||
|
<div class="knob">
|
||||||
|
<span class="knob__label">symbols</span>
|
||||||
|
<div class="panel-toggle" style="flex:1;">
|
||||||
|
<button data-symbol-charset="safe_only" type="button" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
|
||||||
|
<button data-symbol-charset="extended" type="button" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBip39Knobs(k: UiKnobs): string {
|
||||||
|
const sepChip = (label: string, sep: string) => `
|
||||||
|
<button data-separator="${sep}" type="button" class="${k.separator === sep ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
const capChip = (label: string, val: string) => `
|
||||||
|
<button data-capitalization="${val}" type="button" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
return `
|
||||||
|
<div class="knob">
|
||||||
|
<span class="knob__label">words</span>
|
||||||
|
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="knob__slider">
|
||||||
|
<span class="knob__value" id="gen-word-count-val">${k.wordCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="knob" style="align-items:flex-start;">
|
||||||
|
<span class="knob__label">separator</span>
|
||||||
|
<div class="panel-toggle" style="flex:1;">
|
||||||
|
${sepChip('space', ' ')}
|
||||||
|
${sepChip('-', '-')}
|
||||||
|
${sepChip('_', '_')}
|
||||||
|
${sepChip('.', '.')}
|
||||||
|
${sepChip(':', ':')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="knob" style="align-items:flex-start;">
|
||||||
|
<span class="knob__label">case</span>
|
||||||
|
<div class="panel-toggle" style="flex:1;">
|
||||||
|
${capChip('lower', 'lower')}
|
||||||
|
${capChip('upper', 'upper')}
|
||||||
|
${capChip('first', 'first_of_each')}
|
||||||
|
${capChip('title', 'title')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
/// Typed-item detail view dispatcher. Each type's renderDetail lives in
|
/// Typed-item detail view dispatcher. Each type's renderDetail lives in
|
||||||
/// its own module under ./types/. Document stays "coming soon" until γ.
|
/// its own module under ./types/. Document stays "coming soon" until γ.
|
||||||
|
|
||||||
import { navigate } from '../popup';
|
import { navigate, getState } from '../../shared/state';
|
||||||
import type { Item } from '../../shared/types';
|
import type { Item } from '../../shared/types';
|
||||||
import { getState } from '../popup';
|
|
||||||
import * as login from './types/login';
|
import * as login from './types/login';
|
||||||
import * as secureNote from './types/secure-note';
|
import * as secureNote from './types/secure-note';
|
||||||
import * as identity from './types/identity';
|
import * as identity from './types/identity';
|
||||||
import * as card from './types/card';
|
import * as card from './types/card';
|
||||||
import * as key from './types/key';
|
import * as key from './types/key';
|
||||||
import * as totp from './types/totp';
|
import * as totp from './types/totp';
|
||||||
|
import * as documentType from './types/document';
|
||||||
|
|
||||||
export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||||||
// Tear down any tickers/handlers from a previous detail render before
|
// Tear down any tickers/handlers from a previous detail render before
|
||||||
@@ -21,6 +21,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
|||||||
card.teardown();
|
card.teardown();
|
||||||
key.teardown();
|
key.teardown();
|
||||||
totp.teardown();
|
totp.teardown();
|
||||||
|
documentType.teardown();
|
||||||
|
|
||||||
const item = getState().selectedItem;
|
const item = getState().selectedItem;
|
||||||
if (!item) { navigate('list'); return; }
|
if (!item) { navigate('list'); return; }
|
||||||
@@ -32,7 +33,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
|||||||
case 'card': return card.renderDetail(app, item);
|
case 'card': return card.renderDetail(app, item);
|
||||||
case 'key': return key.renderDetail(app, item);
|
case 'key': return key.renderDetail(app, item);
|
||||||
case 'totp': return totp.renderDetail(app, item);
|
case 'totp': return totp.renderDetail(app, item);
|
||||||
case 'document': return renderComingSoon(app, item);
|
case 'document': return documentType.renderDetail(app, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||