Compare commits
366 Commits
plan-1c-al
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4777cc0bb | ||
|
|
4b657e71f1 | ||
|
|
7901c2758d | ||
|
|
2e41e0bae0 | ||
|
|
b9bd152e9d | ||
|
|
89090a8f30 | ||
|
|
73a2579fa8 | ||
|
|
f3d6c0a880 | ||
|
|
97c8f994e1 | ||
|
|
f3cdbed7b6 | ||
|
|
2d1f0926ae | ||
|
|
0c9387fb1d | ||
|
|
f8296fa03b | ||
|
|
64275bc64f | ||
|
|
2d5b86bf20 | ||
|
|
08bdfbc7c4 | ||
|
|
3811b07014 | ||
|
|
6676d2502b | ||
|
|
615afd7483 | ||
|
|
229e483430 | ||
|
|
c2f3c35ac9 | ||
|
|
530c479f19 | ||
|
|
da7d7d162c | ||
|
|
03d0781c39 | ||
|
|
13c2fc2bd7 | ||
|
|
b9b07ec68d | ||
|
|
17bde162cd | ||
|
|
52400230e0 | ||
|
|
272b6a3845 | ||
|
|
02e05f7a05 | ||
|
|
1e858e1d1f | ||
|
|
bd3d53fddb | ||
|
|
3b09adf3b2 | ||
|
|
4f7ab91f14 | ||
|
|
4a726c2631 | ||
|
|
450de33c0a | ||
|
|
dd0010db62 | ||
|
|
29146439bb | ||
|
|
cf66bd97b7 | ||
|
|
061facd5a9 | ||
|
|
bd6a30155e | ||
|
|
8baef5b3cb | ||
|
|
ddfb95d683 | ||
|
|
7df76c692a | ||
|
|
b4d253c60b | ||
|
|
c16adc4335 | ||
|
|
9a8cdf8e4f | ||
|
|
ade44b4ea1 | ||
|
|
1d4b018f9a | ||
|
|
882a89bedd | ||
|
|
37c20b28a6 | ||
|
|
3553150a53 | ||
|
|
b50f49b597 | ||
|
|
1ec8965910 | ||
|
|
ad6e4a2cd9 | ||
|
|
b768f649a2 | ||
|
|
8b197a7525 | ||
|
|
117716f6cf | ||
|
|
c5e8b52e12 | ||
|
|
a1b66a9147 | ||
|
|
934dfe05c2 | ||
|
|
33d2a4a311 | ||
|
|
f17944a404 | ||
|
|
4851857070 | ||
|
|
a6071b4c0c | ||
|
|
ada00895d4 | ||
|
|
42b746f9af | ||
|
|
762a008171 | ||
|
|
f93bce7388 | ||
|
|
8eabaf5f31 | ||
|
|
04142dc116 | ||
|
|
8739f1f67b | ||
|
|
7d6fd76e86 | ||
|
|
4dc034d846 | ||
|
|
3021ef9d9f | ||
|
|
b2749826b1 | ||
|
|
a332a9e80d | ||
|
|
d45dd10917 | ||
|
|
4d02a50cc8 | ||
|
|
4e9d834920 | ||
|
|
631e9af470 | ||
|
|
b2fc56709a | ||
|
|
b928ed407b | ||
|
|
5d9a7ee8d3 | ||
|
|
006e67c361 | ||
|
|
95d1ff833c | ||
|
|
6bca0b3526 | ||
|
|
f45c275566 | ||
|
|
3e4312ca6f | ||
|
|
4fc1357368 | ||
|
|
518b41e9cd | ||
|
|
df58b0dda1 | ||
|
|
ed9fcbe6ba | ||
|
|
0172a06698 | ||
|
|
1de7cda1b0 | ||
|
|
6d5a2570d4 | ||
|
|
6a1c6d5875 | ||
|
|
6d8f699fcb | ||
|
|
25c9eb52a0 | ||
|
|
2df636e454 | ||
|
|
c0921b134d | ||
|
|
575343dc19 | ||
|
|
0443f6a3b4 | ||
|
|
5e8e617a4d | ||
|
|
1c641b4911 | ||
|
|
efac53d527 | ||
|
|
214e1e49f8 | ||
|
|
af8626fb5f | ||
|
|
9c97f9f939 | ||
|
|
76d092d4f6 | ||
|
|
648dcf386e | ||
|
|
1342228a51 | ||
|
|
d539050aec | ||
|
|
8fd9a05875 | ||
|
|
8a72b5e192 | ||
|
|
ca059e7507 | ||
|
|
c3d8778042 | ||
|
|
900ccf1cf4 | ||
|
|
3caa7af194 | ||
|
|
57237af39e | ||
|
|
5da1e520e3 | ||
|
|
f1c615c0ed | ||
|
|
b270dfedb4 | ||
|
|
a28b456191 | ||
|
|
058a49f68b | ||
|
|
97e351fa61 | ||
|
|
7371eff0bb | ||
|
|
308ef2c974 | ||
|
|
60d7c074c3 | ||
|
|
91536ee50d | ||
|
|
da61529de6 | ||
|
|
7370f119ee | ||
|
|
479e5848f5 | ||
|
|
d038b24c6b | ||
|
|
d6d07a19c1 | ||
|
|
d0047e751f | ||
|
|
8bf21501a5 | ||
|
|
b1af0a11bc | ||
|
|
c67d484152 | ||
|
|
fb1f28161c | ||
|
|
520f6ec72c | ||
|
|
9845febb74 | ||
|
|
15d691abb2 | ||
|
|
b1f9f2fbfc | ||
|
|
61f2f9c18f | ||
|
|
7e07d5d664 | ||
|
|
dc683c7e4c | ||
|
|
8e26c8708b | ||
|
|
b9f44a3d4f | ||
|
|
d6703be2b1 | ||
|
|
81f1f8ec31 | ||
|
|
2739eb4194 | ||
|
|
628e2bd636 | ||
|
|
466efe4b8a | ||
|
|
bbdbcca87b | ||
|
|
27c4ac69cb | ||
|
|
3d3e9ac7f2 | ||
|
|
71d51c0bea | ||
|
|
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 | ||
|
|
706051530e | ||
|
|
23759dc163 | ||
|
|
3c0b4c1589 | ||
|
|
673981379e | ||
|
|
e084790756 | ||
|
|
560a3c63c4 | ||
|
|
113b0b690a | ||
|
|
99d689b9b0 | ||
|
|
23d4f736e1 | ||
|
|
11c274053b | ||
|
|
24a99ba07a | ||
|
|
beac303a77 | ||
|
|
b80b322853 | ||
|
|
1b51b7dbab | ||
|
|
2b83105149 |
11
.claude/settings.json
Normal file
11
.claude/settings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"relay": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:7331/sse"
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,3 +7,10 @@ extension/dist-firefox/
|
||||
extension/wasm/
|
||||
reference.jpg
|
||||
ref.jpg
|
||||
tools/relay/node_modules/
|
||||
|
||||
# Local Gitea credentials (do not commit)
|
||||
.gitea_env_vars
|
||||
|
||||
# Scratch reviewer subagent output (raw drafts; canonical notes live in docs/superpowers/reviews/2026-*-dev-*-notes.md)
|
||||
docs/superpowers/reviews/.dev-c-content.md
|
||||
|
||||
218
CHANGELOG.md
Normal file
218
CHANGELOG.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Changelog
|
||||
|
||||
## v0.5.0 — 2026-05-02
|
||||
|
||||
Three release trains roll into one tag — backup/restore + LastPass
|
||||
import (originally v0.3.0), device authentication (originally v0.4.0),
|
||||
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
|
||||
two confirmed bugs).
|
||||
|
||||
### Security
|
||||
|
||||
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
|
||||
Earlier `relicario-server` builds accepted any commit with a
|
||||
`Good signature` line on stderr regardless of which key signed it —
|
||||
device-auth was a no-op. The hook now builds an `allowed_signers`
|
||||
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
|
||||
global git-config mutation), parses the SSH SHA-256 fingerprint out
|
||||
of `git verify-commit --raw` stderr, and rejects unregistered keys or
|
||||
revoked keys whose committer-date is at or after the revocation
|
||||
timestamp. Bootstrap mode is preserved only when **both**
|
||||
`devices.json` AND `revoked.json` are empty (closes an
|
||||
empty-devices.json privilege-escalation route).
|
||||
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
|
||||
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
|
||||
A new `relicario_core::safe_unpack_git_archive` validates each
|
||||
entry's path components (rejects `..`, absolute paths, Windows
|
||||
drive prefixes), rejects symlinks/hardlinks, and caps total
|
||||
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
|
||||
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
|
||||
check after path-joining as defense-in-depth.
|
||||
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
|
||||
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
|
||||
developer escape hatch, not a user knob) is now
|
||||
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
|
||||
the env-var lookup is removed from the binary by the optimiser.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Strength meter no longer goes stale after the regenerate button (B1).**
|
||||
Programmatic `input.value = newPassword` doesn't fire `input`
|
||||
events; the regenerate handler now dispatches a synthetic
|
||||
`InputEvent('input', { bubbles: true })` so the meter listener
|
||||
re-rates the new value.
|
||||
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
|
||||
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
|
||||
used to render verbatim in the fullscreen vault tab and (in some
|
||||
cases) the popup. New `extension/src/shared/error-copy.ts` central
|
||||
registry maps every service-worker error code to friendly
|
||||
title/body/CTA copy; the popup and fullscreen tab consume the
|
||||
same map. The fullscreen lock screen's `vault_locked` block now
|
||||
reads `Vault locked / Unlock your vault to continue. / [Unlock
|
||||
vault]`. A generated test enumerates the live error codes via
|
||||
grep so the registry can't drift.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sidebar logo in the fullscreen vault tab.** The
|
||||
`vault-sidebar__header` now renders the 16-optimized SVG logo
|
||||
inline before the "Relicario" wordmark (20×20 px, `flex-shrink: 0`
|
||||
so it survives narrow-pane wraps). Popup unaffected.
|
||||
- **Password coloring (P1).** Revealed passwords in the popup
|
||||
item-detail, fullscreen item view, field-history viewer, and
|
||||
generator preview render digits and symbols in distinct colors.
|
||||
Defaults: blue digits, red symbols. Users can override via the
|
||||
new Display section in settings (color pickers + live preview
|
||||
swatch + reset). Defaults round-trip via
|
||||
`chrome.storage.sync.password_display_scheme`; cross-device when
|
||||
Chrome sync is enabled.
|
||||
- **Setup wizard hands off to the fullscreen vault tab on completion
|
||||
(P2).** Both create-new and attach-existing flows now open
|
||||
`vault.html` in a new tab and best-effort close the setup tab
|
||||
after device registration succeeds — replaces the prior
|
||||
setup-tab-stays-open terminal screen.
|
||||
- **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.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Form layout in the fullscreen vault tab is now visually consistent
|
||||
(P3).** Notes, custom-fields disclosure, attachments disclosure, and
|
||||
form-actions in fullscreen logins now sit inside a `.form-lower`
|
||||
wrapper with the same `max-width: 960px; margin: 0 auto` envelope as
|
||||
the `.form-grid` cards above. Removes the visual rhythm break at the
|
||||
2-col → full-width transition. The popup surface is unchanged.
|
||||
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
|
||||
`docs/architecture/overview.md` now describes four codebases (the
|
||||
`relicario-server` pre-receive hook crate is no longer invisible);
|
||||
`CLAUDE.md` project tree and roadmap reflect current state;
|
||||
`docs/SECURITY.md` names the server crate and its `verify-commit` /
|
||||
`generate-hook` subcommands and notes the without-the-hook-it's-
|
||||
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
|
||||
parallel artifact in the vault-creation flow; the foundational
|
||||
design spec gains a "historical" status banner pointing readers at
|
||||
the current docs.
|
||||
- `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).
|
||||
|
||||
### 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
|
||||
|
||||
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
|
||||
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
|
||||
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
|
||||
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
|
||||
builds clean under `-D warnings`.
|
||||
- `Cargo.lock` regenerated and committed; was stale since the
|
||||
`--totp-qr` commit.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
23
CLAUDE.md
23
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
|
||||
|
||||
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
|
||||
|
||||
@@ -41,9 +48,11 @@ crates/
|
||||
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||
└── relicario-wasm/ # WASM bindings for the extension
|
||||
├── src/lib.rs # #[wasm_bindgen] surface
|
||||
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||
├── relicario-wasm/ # WASM bindings for the extension
|
||||
│ ├── src/lib.rs # #[wasm_bindgen] surface
|
||||
│ └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
|
||||
└── src/main.rs # verify-commit + generate-hook subcommands
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
@@ -69,7 +78,7 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
||||
|
||||
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
|
||||
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
|
||||
- Item IDs are random 8-char hex strings.
|
||||
- Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits).
|
||||
- Git history is preserved as an audit log — no squashing.
|
||||
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
||||
|
||||
@@ -83,4 +92,4 @@ Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2
|
||||
|
||||
## Roadmap
|
||||
|
||||
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).
|
||||
Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.
|
||||
|
||||
1173
Cargo.lock
generated
1173
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,4 +4,5 @@ members = [
|
||||
"crates/relicario-core",
|
||||
"crates/relicario-cli",
|
||||
"crates/relicario-wasm",
|
||||
"crates/relicario-server",
|
||||
]
|
||||
|
||||
62
README.md
62
README.md
@@ -1,8 +1,8 @@
|
||||
<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>
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -23,7 +23,7 @@ Your reference photo (something you have)
|
||||
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.
|
||||
|
||||
@@ -33,7 +33,9 @@ To unlock the vault, you provide your passphrase and point the client at the ref
|
||||
|
||||
A git repository containing:
|
||||
- `manifest.enc` — opaque binary blob
|
||||
- `entries/*.enc` — more opaque binary blobs
|
||||
- `items/*.enc` — more opaque binary blobs
|
||||
- `attachments/<item-id>/*.enc` — encrypted attachment blobs
|
||||
- `settings.enc` — encrypted vault settings
|
||||
- `.relicario/salt` — a random 32-byte value (not secret)
|
||||
- `.relicario/params.json` — Argon2id parameters (not secret)
|
||||
- `.relicario/devices.json` — authorized device public keys
|
||||
@@ -58,7 +60,7 @@ No single point of failure. The two-factor design means the passphrase alone can
|
||||
| LastPass | ~40-60 bits (master password only) | 1 |
|
||||
| Bitwarden | ~40-60 bits (master password only) | 1 |
|
||||
| 1Password | password + 128-bit Secret Key | 2 |
|
||||
| **relicario** | **password + 256-bit image secret** | **2** |
|
||||
| **Relicario** | **password + 256-bit image secret** | **2** |
|
||||
|
||||
### What we don't protect against
|
||||
|
||||
@@ -114,12 +116,23 @@ relicario/
|
||||
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
|
||||
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
|
||||
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
|
||||
│ │ ├── entry.rs # Entry, Manifest data model (serde)
|
||||
│ │ └── vault.rs # Encrypt/decrypt entries and manifests
|
||||
│ └── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
|
||||
│ │ ├── item.rs # Item, Field, Manifest data model (serde)
|
||||
│ │ ├── item_types/ # Per-type cores (Login, SecureNote, Card, Identity, Key, Document, Totp)
|
||||
│ │ ├── attachment.rs # Encrypted attachment helpers (content-addressed)
|
||||
│ │ ├── settings.rs # VaultSettings (retention, generator defaults, caps)
|
||||
│ │ ├── backup.rs # `.relbak` encrypted-backup envelope
|
||||
│ │ ├── device.rs # ed25519 device keys + revocation entries
|
||||
│ │ └── vault.rs # Encrypt/decrypt items, manifest, settings
|
||||
│ ├── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
|
||||
│ ├── relicario-wasm/ # Thin wasm-bindgen wrapper for the browser extension
|
||||
│ └── relicario-server/ # Pre-receive hook: device-signature verification
|
||||
├── extension/ # Chrome MV3 / Firefox WebExtension (TypeScript)
|
||||
└── docs/
|
||||
├── ARCHITECTURE.md # System overview + flow diagrams
|
||||
├── SECURITY.md # Manifest integrity model + threat notes
|
||||
├── architecture/ # Cross-codebase + per-codebase architecture docs
|
||||
└── superpowers/
|
||||
└── specs/ # Design specification with full threat model
|
||||
└── specs/ # Design specifications with full threat model
|
||||
```
|
||||
|
||||
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
|
||||
@@ -144,17 +157,22 @@ Every write generates a fresh random nonce. The version byte allows future forma
|
||||
|
||||
```
|
||||
my-vault.git/
|
||||
├── manifest.enc # Encrypted entry index (names, URLs, timestamps)
|
||||
├── entries/
|
||||
│ ├── a1b2c3d4.enc # One encrypted entry per file
|
||||
│ └── e5f6a7b8.enc
|
||||
├── manifest.enc # Encrypted item index (names, URLs, timestamps)
|
||||
├── settings.enc # Encrypted vault settings (retention, caps, generator defaults)
|
||||
├── items/
|
||||
│ ├── a1b2c3d4e5f6a7b8.enc # One encrypted item per file
|
||||
│ └── …
|
||||
├── attachments/
|
||||
│ └── <item-id>/
|
||||
│ └── <aid>.enc # Content-addressed encrypted attachment blob
|
||||
└── .relicario/
|
||||
├── salt # 32-byte random salt (not secret)
|
||||
├── params.json # KDF parameters
|
||||
└── devices.json # Authorized device public keys
|
||||
├── devices.json # Authorized device public keys
|
||||
└── revoked.json # Revoked device records (when device auth is enabled)
|
||||
```
|
||||
|
||||
Entry IDs are random hex strings. Git history is preserved — every add/edit/delete is a commit. "When was this password last rotated?" is answered by `git log`.
|
||||
Item IDs are random 16-char hex strings (64 bits of entropy). Git history is preserved — every add/edit/delete is a commit. "When was this password last rotated?" is answered by `git log` and by the per-item field history.
|
||||
|
||||
## Device management
|
||||
|
||||
@@ -183,13 +201,17 @@ The binary is at `target/release/relicario`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] WASM build + Chrome browser extension (inline crypto, no native messaging)
|
||||
- [ ] Secure notes (free-form encrypted text entries)
|
||||
- [ ] Secure document storage (encrypted file attachments up to 5-10 MB)
|
||||
- [x] WASM build + Chrome MV3 browser extension (inline crypto, no native messaging)
|
||||
- [x] Firefox WebExtension build
|
||||
- [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP
|
||||
- [x] Secure document storage (encrypted file attachments)
|
||||
- [x] Backup & restore (`.relbak` encrypted envelope)
|
||||
- [x] LastPass CSV import
|
||||
- [x] Device authentication (ed25519 commit signing + pre-receive hook)
|
||||
- [ ] Import from Bitwarden / 1Password
|
||||
- [ ] `relicario unlock` daemon (ssh-agent-style, holds master key for a TTL)
|
||||
- [ ] Android/iOS clients (Rust core compiles to ARM)
|
||||
- [ ] Import from LastPass/Bitwarden/1Password
|
||||
- [ ] Firefox/Safari extensions
|
||||
- [ ] Safari extension
|
||||
|
||||
## License
|
||||
|
||||
|
||||
539
crates/relicario-cli/ARCHITECTURE.md
Normal file
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]
|
||||
name = "relicario-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "CLI for relicario password manager"
|
||||
|
||||
@@ -17,17 +17,21 @@ arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
dirs = "5"
|
||||
hex = "0.4"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
zeroize = "1"
|
||||
url = "2"
|
||||
data-encoding = "2"
|
||||
tar = { version = "0.4", default-features = false }
|
||||
clap_complete = "4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
rqrr = "0.7"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
qrcode = { version = "0.14", features = ["svg"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
serde_json = "1"
|
||||
|
||||
313
crates/relicario-cli/src/commands/add.rs
Normal file
313
crates/relicario-cli/src/commands/add.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
//! `relicario add <kind>` — create a new item of the given type.
|
||||
//!
|
||||
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
|
||||
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
|
||||
//! `Document` builder is the only one that needs the unlocked vault (for the
|
||||
//! attachment-cap settings + writing the encrypted blob alongside the item).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::AddKind;
|
||||
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
||||
use crate::prompt::{prompt, prompt_optional, prompt_secret};
|
||||
|
||||
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
|
||||
let item = match kind {
|
||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
|
||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
||||
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
||||
build_secure_note_item(title, body_prompt, group, tags)?,
|
||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
||||
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
|
||||
AddKind::Card { title, holder, expiry, kind, group, tags } =>
|
||||
build_card_item(title, holder, expiry, kind, group, tags)?,
|
||||
AddKind::Key { title, label, algorithm, group, tags } =>
|
||||
build_key_item(title, label, algorithm, group, tags)?,
|
||||
AddKind::Document { title, file, group, tags } =>
|
||||
build_document_item(&vault, title, file, group, tags)?,
|
||||
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
|
||||
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
|
||||
};
|
||||
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let mut paths: Vec<String> = vec![
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
"manifest.enc".into(),
|
||||
];
|
||||
for att in &item.attachments {
|
||||
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
||||
}
|
||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
super::commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
|
||||
|
||||
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_login_item(
|
||||
title: Option<String>,
|
||||
username: Option<String>,
|
||||
url: Option<String>,
|
||||
password_prompt: bool,
|
||||
password: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
favorite: bool,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
|
||||
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
|
||||
let parsed_url = match url {
|
||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||
None => None,
|
||||
};
|
||||
let password = if let Some(p) = password {
|
||||
Some(Zeroizing::new(p))
|
||||
} else if password_prompt {
|
||||
Some(Zeroizing::new(prompt_secret("Password: ")?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let totp = if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
||||
username, password, url: parsed_url, totp,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
item.favorite = favorite;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_secure_note_item(
|
||||
title: Option<String>,
|
||||
body_prompt: bool,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::SecureNoteCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let body = if body_prompt {
|
||||
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
s
|
||||
} else {
|
||||
prompt("Body")?
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new(body),
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_identity_item(
|
||||
title: Option<String>,
|
||||
full_name: Option<String>,
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
date_of_birth: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::IdentityCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let dob = match date_of_birth {
|
||||
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||
None => None,
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
|
||||
full_name, address: None, phone, email, date_of_birth: dob,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_card_item(
|
||||
title: Option<String>,
|
||||
holder: Option<String>,
|
||||
expiry: Option<String>,
|
||||
kind: String,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{CardCore, CardKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
||||
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
||||
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
||||
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
|
||||
let pin = if pin.is_empty() { None } else { Some(pin) };
|
||||
|
||||
let parsed_expiry = match expiry {
|
||||
Some(s) => Some(parse_month_year(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let parsed_kind = match kind.as_str() {
|
||||
"credit" => CardKind::Credit,
|
||||
"debit" => CardKind::Debit,
|
||||
"gift" => CardKind::Gift,
|
||||
"loyalty" => CardKind::Loyalty,
|
||||
"other" => CardKind::Other,
|
||||
other => anyhow::bail!("unknown card kind: {other}"),
|
||||
};
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Card(CardCore {
|
||||
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_key_item(
|
||||
title: Option<String>,
|
||||
label: Option<String>,
|
||||
algorithm: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::KeyCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
||||
let mut key_material = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
||||
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||
let public_key = prompt_optional("Public key (blank to skip)")?;
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Key(KeyCore {
|
||||
key_material: Zeroizing::new(key_material),
|
||||
label, public_key, algorithm,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_document_item(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
title: Option<String>,
|
||||
file: PathBuf,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::DocumentCore;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||||
use std::fs;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let bytes = fs::read(&file)
|
||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||
let caps = vault.load_settings()?.attachment_caps;
|
||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = guess_mime(&filename);
|
||||
|
||||
let primary_attachment = enc.id.clone();
|
||||
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||
filename: filename.clone(),
|
||||
mime_type: mime_type.clone(),
|
||||
primary_attachment: primary_attachment.clone(),
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
item.attachments.push(AttachmentRef {
|
||||
id: primary_attachment.clone(),
|
||||
filename, mime_type,
|
||||
size: bytes.len() as u64,
|
||||
created: item.created,
|
||||
});
|
||||
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
fs::create_dir_all(&att_dir)?;
|
||||
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_totp_item(
|
||||
title: Option<String>,
|
||||
issuer: Option<String>,
|
||||
label: Option<String>,
|
||||
secret: Option<String>,
|
||||
period: u32,
|
||||
digits: u8,
|
||||
algorithm: String,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let secret_b32 = match secret {
|
||||
Some(s) => s,
|
||||
None => prompt_secret("TOTP secret (base32): ")?,
|
||||
};
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
let algo = match algorithm.to_ascii_lowercase().as_str() {
|
||||
"sha1" => TotpAlgorithm::Sha1,
|
||||
"sha256" => TotpAlgorithm::Sha256,
|
||||
"sha512" => TotpAlgorithm::Sha512,
|
||||
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||
};
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
|
||||
config: TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: algo,
|
||||
digits,
|
||||
period_seconds: period,
|
||||
kind: TotpKind::Totp,
|
||||
},
|
||||
issuer, label,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
175
crates/relicario-cli/src/commands/attach.rs
Normal file
175
crates/relicario-cli/src/commands/attach.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::parse::guess_mime;
|
||||
|
||||
pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
let settings = vault.load_settings()?;
|
||||
let caps = settings.attachment_caps;
|
||||
|
||||
if item.attachments.len() as u32 >= caps.per_item_max_count {
|
||||
anyhow::bail!("item already has {} attachments (max {})",
|
||||
item.attachments.len(), caps.per_item_max_count);
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file)
|
||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||
|
||||
// Check per-vault total attachment bytes cap (audit I3).
|
||||
let current_total: u64 = manifest.items.values()
|
||||
.flat_map(|e| &e.attachment_summaries)
|
||||
.map(|s| s.size)
|
||||
.sum();
|
||||
let new_size = bytes.len() as u64;
|
||||
let hard_cap = caps.per_vault_hard_cap_bytes;
|
||||
let soft_cap = caps.per_vault_soft_cap_bytes;
|
||||
if current_total + new_size > hard_cap {
|
||||
anyhow::bail!(
|
||||
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
|
||||
current_total, new_size, hard_cap
|
||||
);
|
||||
}
|
||||
if current_total + new_size > soft_cap {
|
||||
eprintln!(
|
||||
"warning: vault attachments will exceed soft cap ({} bytes)",
|
||||
soft_cap
|
||||
);
|
||||
}
|
||||
|
||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = guess_mime(&filename);
|
||||
let aref = AttachmentRef {
|
||||
id: enc.id.clone(),
|
||||
filename,
|
||||
mime_type,
|
||||
size: bytes.len() as u64,
|
||||
created: now_unix(),
|
||||
};
|
||||
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
fs::create_dir_all(&att_dir)?;
|
||||
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||
|
||||
item.attachments.push(aref);
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let paths = [
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
"manifest.enc".into(),
|
||||
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
|
||||
];
|
||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
super::commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||
crate::helpers::sanitize_for_commit(&file.display().to_string()),
|
||||
crate::helpers::sanitize_for_commit(&item.title),
|
||||
item.id.as_str()), &path_refs)?;
|
||||
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_attachments(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||
for a in &item.attachments {
|
||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::decrypt_attachment;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
let path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", aid));
|
||||
let bytes = fs::read(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let plaintext = decrypt_attachment(&bytes, vault.key())?;
|
||||
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
|
||||
fs::write(&out_path, plaintext.as_slice())
|
||||
.with_context(|| format!("failed to write {}", out_path.display()))?;
|
||||
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::ItemCore;
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
|
||||
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
|
||||
// Document items keep their primary blob in the core; refuse to orphan it.
|
||||
if let ItemCore::Document(d) = &item.core {
|
||||
if d.primary_attachment.as_str() == aid {
|
||||
anyhow::bail!(
|
||||
"cannot detach the primary attachment of a Document item; \
|
||||
use `purge {}` to delete the whole item",
|
||||
item.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let removed = item.attachments.remove(pos);
|
||||
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", removed.id.as_str()));
|
||||
if blob_path.exists() {
|
||||
fs::remove_file(&blob_path)
|
||||
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||
super::commit_paths(
|
||||
&vault,
|
||||
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&item_path, "manifest.enc", &blob_relpath],
|
||||
)?;
|
||||
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||
Ok(())
|
||||
}
|
||||
303
crates/relicario-cli/src/commands/backup.rs
Normal file
303
crates/relicario-cli/src/commands/backup.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! `relicario backup export` / `relicario backup restore` — pack/unpack the
|
||||
//! encrypted `.relbak` envelope.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::BackupAction;
|
||||
|
||||
pub fn cmd_backup(action: BackupAction) -> Result<()> {
|
||||
match action {
|
||||
BackupAction::Export { out, include_image, image, no_history } => {
|
||||
cmd_backup_export(out, include_image, image, no_history)
|
||||
}
|
||||
BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cmd_backup_export(
|
||||
out: PathBuf,
|
||||
include_image: bool,
|
||||
image: Option<PathBuf>,
|
||||
no_history: bool,
|
||||
) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::{backup, validate_passphrase_strength};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
|
||||
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
||||
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||
};
|
||||
let confirm = if crate::test_backup_passphrase_override().is_some() {
|
||||
passphrase.clone()
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||
};
|
||||
if passphrase.as_str() != confirm.as_str() {
|
||||
anyhow::bail!("passphrases do not match");
|
||||
}
|
||||
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||
anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e);
|
||||
}
|
||||
|
||||
// Read everything from disk that the envelope needs.
|
||||
let salt = fs::read(root.join(".relicario").join("salt"))
|
||||
.with_context(|| "failed to read .relicario/salt")?;
|
||||
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||
.with_context(|| "failed to read .relicario/params.json")?;
|
||||
// devices.json was removed in the B1 security audit fix; fall back to
|
||||
// an empty array so backups of post-B1 vaults still pack cleanly.
|
||||
// Task 12 will remove the devices field from the backup format entirely.
|
||||
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let manifest_enc = fs::read(root.join("manifest.enc"))
|
||||
.with_context(|| "failed to read manifest.enc")?;
|
||||
let settings_enc = fs::read(root.join("settings.enc"))
|
||||
.with_context(|| "failed to read settings.enc")?;
|
||||
|
||||
// Items.
|
||||
let mut item_files = Vec::new();
|
||||
let items_dir = root.join("items");
|
||||
if items_dir.is_dir() {
|
||||
for entry in fs::read_dir(&items_dir)? {
|
||||
let p = entry?.path();
|
||||
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||
let id = p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
|
||||
.to_string();
|
||||
let bytes = fs::read(&p)?;
|
||||
item_files.push((id, bytes));
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments. Layout: attachments/<item_id>/<aid>.enc
|
||||
let mut attach_files = Vec::new();
|
||||
let attach_dir = root.join("attachments");
|
||||
if attach_dir.is_dir() {
|
||||
for entry in fs::read_dir(&attach_dir)? {
|
||||
let item_dir = entry?.path();
|
||||
if !item_dir.is_dir() { continue; }
|
||||
let item_id = item_dir.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
|
||||
.to_string();
|
||||
for sub in fs::read_dir(&item_dir)? {
|
||||
let p = sub?.path();
|
||||
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||
let aid = p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
|
||||
.to_string();
|
||||
let bytes = fs::read(&p)?;
|
||||
attach_files.push((item_id.clone(), aid, bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional reference image.
|
||||
let image_bytes = if include_image {
|
||||
let path = match image {
|
||||
Some(p) => p,
|
||||
None => crate::session::get_image_path()?,
|
||||
};
|
||||
Some(fs::read(&path)
|
||||
.with_context(|| format!("failed to read reference image {}", path.display()))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Optional .git/ tar.
|
||||
let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };
|
||||
|
||||
let items_refs: Vec<backup::BackupItem> = item_files.iter()
|
||||
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
|
||||
.collect();
|
||||
let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
|
||||
.map(|(iid, aid, bytes)| backup::BackupAttachment {
|
||||
item_id: iid.clone(),
|
||||
attachment_id: aid.clone(),
|
||||
ciphertext: bytes,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let input = backup::BackupInput {
|
||||
salt: &salt,
|
||||
params_json: ¶ms_json,
|
||||
devices_json: &devices_json,
|
||||
manifest_enc: &manifest_enc,
|
||||
settings_enc: &settings_enc,
|
||||
items: items_refs,
|
||||
attachments: attach_refs,
|
||||
reference_jpg: image_bytes.as_deref(),
|
||||
git_archive: git_archive.as_deref(),
|
||||
};
|
||||
|
||||
let bytes = backup::pack_backup(input, &passphrase)?;
|
||||
|
||||
// atomic_write via the existing pattern: write `.tmp`, rename.
|
||||
let tmp = {
|
||||
let mut t = out.as_os_str().to_owned();
|
||||
t.push(".tmp");
|
||||
PathBuf::from(t)
|
||||
};
|
||||
fs::write(&tmp, &bytes)
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &out)
|
||||
.with_context(|| format!("failed to rename {}", out.display()))?;
|
||||
|
||||
// Marker file for `cmd_status`. Format: ISO-8601 UTC line.
|
||||
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||
fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;
|
||||
|
||||
let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
|
||||
eprintln!(
|
||||
"Wrote {} ({:.2} MiB). Delete after restore is verified.",
|
||||
out.display(), mib
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
|
||||
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut buf);
|
||||
builder.append_dir_all(".", dir)
|
||||
.with_context(|| format!("failed to tar {}", dir.display()))?;
|
||||
builder.finish().with_context(|| "failed to finalize git tar")?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::backup;
|
||||
use relicario_core::{ItemId, AttachmentId};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let target = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
std::env::current_dir()?.join(&target)
|
||||
};
|
||||
|
||||
if target.join(".relicario").exists() {
|
||||
anyhow::bail!(
|
||||
"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
fs::create_dir_all(&target)
|
||||
.with_context(|| format!("failed to create target {}", target.display()))?;
|
||||
|
||||
// Read input file.
|
||||
let bytes = fs::read(&input)
|
||||
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
||||
|
||||
// Backup passphrase prompt.
|
||||
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||
};
|
||||
|
||||
let unpacked = backup::unpack_backup(&bytes, &passphrase)
|
||||
.map_err(|e| match e {
|
||||
relicario_core::RelicarioError::Decrypt =>
|
||||
anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
|
||||
other => anyhow::anyhow!(other),
|
||||
})?;
|
||||
|
||||
// Write vault layout.
|
||||
let relicario_dir = target.join(".relicario");
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::create_dir_all(target.join("items"))?;
|
||||
fs::create_dir_all(target.join("attachments"))?;
|
||||
|
||||
fs::write(relicario_dir.join("salt"), unpacked.salt)?;
|
||||
fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
|
||||
fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
|
||||
fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
|
||||
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
||||
|
||||
for item in &unpacked.items {
|
||||
let item_id = ItemId(item.id.clone());
|
||||
if !item_id.is_valid() {
|
||||
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
|
||||
}
|
||||
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
||||
}
|
||||
for a in &unpacked.attachments {
|
||||
let item_id = ItemId(a.item_id.clone());
|
||||
let att_id = AttachmentId(a.attachment_id.clone());
|
||||
if !item_id.is_valid() || !att_id.is_valid() {
|
||||
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
|
||||
}
|
||||
let dir = target.join("attachments").join(&a.item_id);
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
||||
}
|
||||
|
||||
// Reference image (if present).
|
||||
if let Some(jpg) = &unpacked.reference_jpg {
|
||||
let path = target.join("reference.jpg");
|
||||
fs::write(&path, jpg)
|
||||
.with_context(|| format!("failed to write reference image {}", path.display()))?;
|
||||
}
|
||||
|
||||
// .git/ history.
|
||||
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||
let cap = std::cmp::min(
|
||||
(tar_bytes.len() as u64).saturating_mul(100),
|
||||
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
||||
);
|
||||
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
||||
.with_context(|| "failed to safely unpack .git/ archive")?;
|
||||
let git_dir = target.join(".git");
|
||||
for (rel_path, body) in entries {
|
||||
let dest = git_dir.join(&rel_path);
|
||||
// Paranoid OS-level check even after textual validation in core.
|
||||
if !dest.starts_with(&git_dir) {
|
||||
anyhow::bail!(
|
||||
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
||||
rel_path.display()
|
||||
);
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("create parent {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
fs::write(&dest, &body).with_context(|| {
|
||||
format!("write {}", dest.display())
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
// No history bundled — start a fresh git repo.
|
||||
crate::helpers::git_run(&target, &["init"], "backup restore: git init")?;
|
||||
|
||||
// .gitignore — exclude reference image if present.
|
||||
if target.join("reference.jpg").exists() {
|
||||
fs::write(target.join(".gitignore"), "reference.jpg\n")?;
|
||||
}
|
||||
|
||||
let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
|
||||
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||
let msg = format!("restore from backup {now_iso}");
|
||||
let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Restored vault to {}. Unlock with your passphrase + reference image.",
|
||||
target.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
255
crates/relicario-cli/src/commands/device.rs
Normal file
255
crates/relicario-cli/src/commands/device.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
//! `relicario device {add, revoke, list}` — device key management.
|
||||
//!
|
||||
//! Note: command bodies live here as `crate::commands::device`. Local key
|
||||
//! storage and git-signing config live separately in `crate::device`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::DeviceAction;
|
||||
|
||||
/// Build a `GiteaClient` from flags or environment variables.
|
||||
fn load_gitea_client(
|
||||
gitea_url: Option<String>,
|
||||
gitea_token: Option<String>,
|
||||
owner: Option<String>,
|
||||
repo: Option<String>,
|
||||
) -> Result<crate::gitea::GiteaClient> {
|
||||
let url = gitea_url
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
|
||||
))?;
|
||||
let token = gitea_token
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
|
||||
))?;
|
||||
let owner = owner
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
|
||||
))?;
|
||||
let repo = repo
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
|
||||
))?;
|
||||
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
|
||||
}
|
||||
|
||||
pub fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
|
||||
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
let relicario_dir = root.join(".relicario");
|
||||
let devices_path = relicario_dir.join("devices.json");
|
||||
|
||||
match action {
|
||||
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
|
||||
// Guard: don't overwrite an already-registered device name.
|
||||
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
if existing.iter().any(|d| d.name == name) {
|
||||
anyhow::bail!("a device named '{}' is already registered", name);
|
||||
}
|
||||
|
||||
eprintln!("Generating signing keypair...");
|
||||
let (signing_priv, signing_pub) = generate_keypair()
|
||||
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
|
||||
|
||||
eprintln!("Generating deploy keypair...");
|
||||
let (deploy_priv, deploy_pub) = generate_keypair()
|
||||
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
|
||||
|
||||
// Optionally register deploy key with Gitea.
|
||||
let gitea_key_id: u64 = if no_gitea {
|
||||
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
|
||||
0
|
||||
} else {
|
||||
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
|
||||
let key_title = format!("relicario-{}", name);
|
||||
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
|
||||
client.create_deploy_key(&key_title, &deploy_pub)?
|
||||
};
|
||||
|
||||
// Store keys locally with proper permissions.
|
||||
crate::device::store_device_keys(
|
||||
&name,
|
||||
&signing_priv,
|
||||
&signing_pub,
|
||||
&deploy_priv,
|
||||
&deploy_pub,
|
||||
gitea_key_id,
|
||||
)?;
|
||||
|
||||
// Mark as current device.
|
||||
crate::device::set_current_device(&name)?;
|
||||
|
||||
// Configure git signing + SSH deploy key in the vault repo.
|
||||
crate::device::configure_git_signing(&root, &name)?;
|
||||
|
||||
// Update devices.json.
|
||||
let current_name = name.clone();
|
||||
let mut devices = existing;
|
||||
devices.push(DeviceEntry {
|
||||
name: name.clone(),
|
||||
public_key: signing_pub.clone(),
|
||||
added_at: relicario_core::now_unix(),
|
||||
added_by: current_name,
|
||||
});
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||
|
||||
// Commit the update.
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["add", ".relicario/devices.json"],
|
||||
&format!("device register \"{name}\": git add .relicario/devices.json"),
|
||||
)?;
|
||||
let msg = format!("device: register {}", name);
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["commit", "-m", &msg],
|
||||
&format!("device register \"{name}\": git commit"),
|
||||
)?;
|
||||
|
||||
eprintln!("Device '{}' registered.", name);
|
||||
eprintln!("Signing public key:");
|
||||
eprintln!(" {}", signing_pub);
|
||||
if gitea_key_id != 0 {
|
||||
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
DeviceAction::Revoke { name } => {
|
||||
// Guard: refuse to revoke the currently active device (would lock
|
||||
// the user out). They must add another device first.
|
||||
if let Some(current) = crate::device::current_device()? {
|
||||
if current == name {
|
||||
anyhow::bail!(
|
||||
"cannot revoke the current device '{}' — you would lose \
|
||||
push access. Register another device first.",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load devices.json.
|
||||
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let device = devices
|
||||
.iter()
|
||||
.find(|d| d.name == name)
|
||||
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
|
||||
.clone();
|
||||
|
||||
// Remove from devices.json.
|
||||
devices.retain(|d| d.name != name);
|
||||
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||
|
||||
// Append to revoked.json.
|
||||
let revoked_path = relicario_dir.join("revoked.json");
|
||||
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let revoked_by = crate::device::current_device()?
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
revoked.push(RevokedEntry {
|
||||
name: name.clone(),
|
||||
public_key: device.public_key.clone(),
|
||||
revoked_at: relicario_core::now_unix(),
|
||||
revoked_by,
|
||||
});
|
||||
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
|
||||
|
||||
// Delete deploy key from Gitea (best-effort — don't fail if it
|
||||
// was already deleted or the config is missing).
|
||||
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
|
||||
if key_id != 0 {
|
||||
// Build client from env vars only (no flags in revoke).
|
||||
match load_gitea_client(None, None, None, None) {
|
||||
Ok(client) => {
|
||||
if let Err(e) = client.delete_deploy_key(key_id) {
|
||||
eprintln!(
|
||||
"warning: failed to delete Gitea deploy key {}: {}",
|
||||
key_id, e
|
||||
);
|
||||
} else {
|
||||
eprintln!("Deleted Gitea deploy key {}.", key_id);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"warning: Gitea env vars not set — deploy key {} \
|
||||
not deleted from Gitea.",
|
||||
key_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit devices.json + revoked.json (always both — revoked.json
|
||||
// was just written above so it is guaranteed to exist).
|
||||
let add_args = [
|
||||
"add",
|
||||
".relicario/devices.json",
|
||||
".relicario/revoked.json",
|
||||
];
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&add_args,
|
||||
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
|
||||
)?;
|
||||
let msg = format!("device: revoke {}", name);
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["commit", "-m", &msg],
|
||||
&format!("device revoke \"{name}\": git commit"),
|
||||
)?;
|
||||
|
||||
eprintln!("Device '{}' revoked.", name);
|
||||
eprintln!("Revoked signing key: {}", device.public_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
DeviceAction::List => {
|
||||
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let current = crate::device::current_device()?.unwrap_or_default();
|
||||
|
||||
if devices.is_empty() {
|
||||
println!("No registered devices.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
||||
println!("{}", "-".repeat(72));
|
||||
for d in &devices {
|
||||
let marker = if d.name == current { " *" } else { "" };
|
||||
let added = crate::helpers::iso8601(d.added_at);
|
||||
// Show only the first 40 chars of the public key line for readability.
|
||||
let key_prefix: String = d.public_key.chars().take(40).collect();
|
||||
println!("{:<20} {:<20} {}{}",
|
||||
d.name, added, key_prefix, marker);
|
||||
}
|
||||
if !current.is_empty() {
|
||||
println!("\n* = current device");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
171
crates/relicario-cli/src/commands/edit.rs
Normal file
171
crates/relicario-cli/src/commands/edit.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
//! `relicario edit <query>` — interactive per-type field editing with history capture.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::parse::base32_decode_lenient;
|
||||
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||
|
||||
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
|
||||
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||
item.title, item.id.as_str());
|
||||
|
||||
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
||||
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
|
||||
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
}
|
||||
|
||||
let history = &mut item.field_history;
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => edit_identity(i)?,
|
||||
ItemCore::Card(c) => edit_card(c, history)?,
|
||||
ItemCore::Key(k) => edit_key(k, history)?,
|
||||
ItemCore::Document(_) => edit_document_message(),
|
||||
ItemCore::Totp(t) => edit_totp(t, history)?,
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Updated {}", item.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
|
||||
// that touch history-tracked fields take the item's field_history map so
|
||||
// they can record the prior value alongside the change.
|
||||
|
||||
type FieldHistory = std::collections::HashMap<
|
||||
relicario_core::FieldId,
|
||||
Vec<relicario_core::item::FieldHistoryEntry>,
|
||||
>;
|
||||
|
||||
fn edit_login(
|
||||
l: &mut relicario_core::item_types::LoginCore,
|
||||
history: &mut FieldHistory,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||
}
|
||||
if prompt_yesno("Change password?")? {
|
||||
let old = l.password.clone();
|
||||
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||
if let Some(old_pw) = old {
|
||||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
l.totp = Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
});
|
||||
eprintln!("TOTP secret set from QR image.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if prompt_yesno("Edit body?")? {
|
||||
let old = n.body.clone();
|
||||
eprintln!("Enter new body; end with Ctrl-D:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
n.body = Zeroizing::new(s);
|
||||
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||
if prompt_yesno("Change card number?")? {
|
||||
let old = c.number.clone();
|
||||
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||
if let Some(o) = old {
|
||||
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if prompt_yesno("Replace key material?")? {
|
||||
eprintln!("Paste new key material; end with Ctrl-D:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
let old = k.key_material.clone();
|
||||
k.key_material = Zeroizing::new(s);
|
||||
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_document_message() {
|
||||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||
}
|
||||
|
||||
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||
if prompt_yesno("Change TOTP secret?")? {
|
||||
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||
t.config.secret = Zeroizing::new(new_bytes);
|
||||
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_history(
|
||||
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
|
||||
synthetic_key: &str,
|
||||
old_value: zeroize::Zeroizing<String>,
|
||||
) {
|
||||
use relicario_core::item::FieldHistoryEntry;
|
||||
use relicario_core::time::now_unix;
|
||||
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||||
// custom-field UUIDs can't collide).
|
||||
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||||
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||||
value: old_value,
|
||||
replaced_at: now_unix(),
|
||||
});
|
||||
}
|
||||
68
crates/relicario-cli/src/commands/generate.rs
Normal file
68
crates/relicario-cli/src/commands/generate.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! `relicario generate` — emit a fresh password or BIP39 passphrase.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_generate(
|
||||
length: Option<u32>,
|
||||
bip39: bool,
|
||||
words: Option<u32>,
|
||||
symbols: Option<String>,
|
||||
separator: Option<String>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::{
|
||||
generate_passphrase, generate_password, Capitalization, CharClasses,
|
||||
GeneratorRequest, SymbolCharset,
|
||||
};
|
||||
|
||||
// If we're inside a vault, unlock and pull `generator_defaults`. Outside
|
||||
// a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
|
||||
let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
Some(vault.load_settings()?.generator_defaults)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
|
||||
// vault default is in (Random when no vault).
|
||||
let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));
|
||||
|
||||
let output = if use_bip39 {
|
||||
let (def_words, def_sep, def_cap) = match &vault_defaults {
|
||||
Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
|
||||
(*word_count, separator.clone(), *capitalization)
|
||||
}
|
||||
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||
};
|
||||
generate_passphrase(&GeneratorRequest::Bip39 {
|
||||
word_count: words.unwrap_or(def_words),
|
||||
separator: separator.unwrap_or(def_sep),
|
||||
capitalization: def_cap,
|
||||
})?
|
||||
} else {
|
||||
let (def_length, def_classes, def_charset) = match &vault_defaults {
|
||||
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
|
||||
(*length, *classes, symbol_charset.clone())
|
||||
}
|
||||
_ => (
|
||||
20,
|
||||
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
SymbolCharset::SafeOnly,
|
||||
),
|
||||
};
|
||||
let symbol_charset = match symbols.as_deref() {
|
||||
None => def_charset,
|
||||
Some("safe") => SymbolCharset::SafeOnly,
|
||||
Some("extended") => SymbolCharset::Extended,
|
||||
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||
};
|
||||
generate_password(&GeneratorRequest::Random {
|
||||
length: length.unwrap_or(def_length),
|
||||
classes: def_classes,
|
||||
symbol_charset,
|
||||
})?
|
||||
};
|
||||
|
||||
println!("{}", output.as_str());
|
||||
Ok(())
|
||||
}
|
||||
107
crates/relicario-cli/src/commands/get.rs
Normal file
107
crates/relicario-cli/src/commands/get.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! `relicario get` — print a single item, masking secrets unless `--show`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
println!("ID: {}", item.id.as_str());
|
||||
println!("Title: {}", item.title);
|
||||
println!("Type: {:?}", item.r#type);
|
||||
if let Some(g) = &item.group { println!("Group: {g}"); }
|
||||
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||
println!("Created: {}", crate::helpers::iso8601(item.created));
|
||||
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||
println!();
|
||||
|
||||
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
l.password.clone()
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
else { println!("Body: ********"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Card(c) => {
|
||||
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
||||
println!("Kind: {:?}", c.kind);
|
||||
c.number.clone()
|
||||
}
|
||||
ItemCore::Key(k) => {
|
||||
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
||||
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
||||
Some(k.key_material.clone())
|
||||
}
|
||||
ItemCore::Document(d) => {
|
||||
println!("Filename: {}", d.filename);
|
||||
println!("MIME: {}", d.mime_type);
|
||||
None
|
||||
}
|
||||
ItemCore::Totp(t) => {
|
||||
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||
println!("Period: {}s", t.config.period_seconds);
|
||||
println!("Digits: {}", t.config.digits);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(secret) = primary_secret {
|
||||
if show {
|
||||
println!("Secret: {}", secret.as_str());
|
||||
} else {
|
||||
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
||||
}
|
||||
if copy {
|
||||
copy_to_clipboard_then_clear(&secret)?;
|
||||
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
||||
use arboard::Clipboard;
|
||||
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
||||
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
||||
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
||||
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
||||
// and then rewrites the clipboard with empty string. Even if the user
|
||||
// copies something else in the interim, we still overwrite once.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
if let Ok(mut cb) = Clipboard::new() {
|
||||
let _ = cb.set_text(String::new());
|
||||
drop(cleared_copy); // zeroize the detached copy
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
88
crates/relicario-cli/src/commands/import.rs
Normal file
88
crates/relicario-cli/src/commands/import.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! `relicario import` — currently only LastPass CSV is supported.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::ImportAction;
|
||||
|
||||
pub fn cmd_import(action: ImportAction) -> Result<()> {
|
||||
match action {
|
||||
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::import_lastpass::parse_lastpass_csv;
|
||||
|
||||
let csv_bytes = fs::read(&csv_path)
|
||||
.with_context(|| format!("failed to read CSV {}", csv_path.display()))?;
|
||||
|
||||
let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;
|
||||
|
||||
if items.is_empty() {
|
||||
// Print all warnings so the user sees why nothing imported.
|
||||
for w in &warnings {
|
||||
print_warning(w);
|
||||
}
|
||||
bail!(
|
||||
"imported 0 items from {} — see warnings above",
|
||||
csv_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
|
||||
let total = items.len();
|
||||
let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
vault.save_item(item)?;
|
||||
manifest.upsert(item);
|
||||
written_paths.push(format!("items/{}.enc", item.id.as_str()));
|
||||
|
||||
let n = idx + 1;
|
||||
if n % 50 == 0 || n == total {
|
||||
eprintln!("[{n}/{total}] importing...");
|
||||
}
|
||||
}
|
||||
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
written_paths.push("manifest.enc".into());
|
||||
|
||||
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||
let csv_filename = csv_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("lastpass.csv");
|
||||
super::commit_paths(
|
||||
&vault,
|
||||
&format!("import: {} items from LastPass ({})", total, csv_filename),
|
||||
&path_refs,
|
||||
)?;
|
||||
|
||||
for w in &warnings {
|
||||
print_warning(w);
|
||||
}
|
||||
// Counts only true skips, not partial imports. Coupled by convention to
|
||||
// the parser's warning message strings: skip messages end in "— skipped",
|
||||
// partial-import messages say "imported without TOTP" / "imported without URL".
|
||||
// If a future warning uses the word "skipped" in any other sense, this filter
|
||||
// will need to switch to an enum tag (see ImportWarning::message).
|
||||
eprintln!(
|
||||
"Imported {}, skipped {} (see warnings above)",
|
||||
total,
|
||||
warnings.iter().filter(|w| w.message.contains("skipped")).count()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
|
||||
let prefix = match &w.title {
|
||||
Some(t) => format!("row {} ({}):", w.row, t),
|
||||
None => format!("row {}:", w.row),
|
||||
};
|
||||
eprintln!("warning: {prefix} {}", w.message);
|
||||
}
|
||||
98
crates/relicario-cli/src/commands/init.rs
Normal file
98
crates/relicario-cli/src/commands/init.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! `relicario init` — bootstrap a fresh vault in the current directory.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use relicario_core::{
|
||||
derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
|
||||
validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let root = std::env::current_dir()?;
|
||||
let relicario_dir = root.join(".relicario");
|
||||
if relicario_dir.exists() {
|
||||
anyhow::bail!(".relicario/ already exists in {}", root.display());
|
||||
}
|
||||
|
||||
// Passphrase with strength gate (audit H3).
|
||||
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||
// TTY prompt so integration tests can run without a real TTY.
|
||||
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
||||
};
|
||||
let confirm = if crate::test_passphrase_override().is_some() {
|
||||
passphrase.clone()
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||
};
|
||||
if passphrase.as_str() != confirm.as_str() {
|
||||
anyhow::bail!("passphrases do not match");
|
||||
}
|
||||
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||
anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
|
||||
}
|
||||
|
||||
// Image secret: 32 random bytes, embedded in the carrier.
|
||||
let image_secret = {
|
||||
let mut buf = Zeroizing::new([0u8; 32]);
|
||||
OsRng.fill_bytes(buf.as_mut_slice());
|
||||
buf
|
||||
};
|
||||
let carrier = fs::read(&image)
|
||||
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||
fs::write(&output, &stego)
|
||||
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||
|
||||
// Vault salt + KDF params.
|
||||
let mut salt = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
|
||||
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::create_dir_all(root.join("items"))?;
|
||||
fs::create_dir_all(root.join("attachments"))?;
|
||||
fs::write(relicario_dir.join("salt"), salt)?;
|
||||
fs::write(
|
||||
relicario_dir.join("params.json"),
|
||||
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?,
|
||||
)?;
|
||||
let manifest = Manifest::new();
|
||||
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||
let settings = VaultSettings::default();
|
||||
fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;
|
||||
|
||||
// .gitignore excludes the reference image.
|
||||
let fname = output.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))?
|
||||
.to_string_lossy();
|
||||
let gitignore = format!("{fname}\n");
|
||||
fs::write(root.join(".gitignore"), gitignore)?;
|
||||
|
||||
// git init + initial commit via hardened wrapper.
|
||||
crate::helpers::git_run(&root, &["init"], "init: git init")?;
|
||||
let _ = crate::helpers::git_command(&root, &[
|
||||
"add", ".gitignore", ".relicario/params.json",
|
||||
".relicario/salt", "manifest.enc", "settings.enc",
|
||||
]).status()?;
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["commit", "-m", "init: new Relicario vault (format v2)"],
|
||||
"init: git commit",
|
||||
)?;
|
||||
|
||||
eprintln!("Vault initialized at {}", root.display());
|
||||
eprintln!("Reference image: {}", output.display());
|
||||
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||
Ok(())
|
||||
}
|
||||
103
crates/relicario-cli/src/commands/list.rs
Normal file
103
crates/relicario-cli/src/commands/list.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! `relicario list` and `relicario history` — both read-only browse paths.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_list(
|
||||
type_filter: Option<String>,
|
||||
group_filter: Option<String>,
|
||||
tag_filter: Option<String>,
|
||||
trashed: bool,
|
||||
) -> Result<()> {
|
||||
use relicario_core::ItemType;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
Some("login") => Some(ItemType::Login),
|
||||
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
|
||||
Some("identity") => Some(ItemType::Identity),
|
||||
Some("card") => Some(ItemType::Card),
|
||||
Some("key") => Some(ItemType::Key),
|
||||
Some("document") => Some(ItemType::Document),
|
||||
Some("totp") => Some(ItemType::Totp),
|
||||
Some(other) => anyhow::bail!("unknown type filter: {other}"),
|
||||
};
|
||||
|
||||
let mut entries: Vec<_> = manifest.items.values()
|
||||
.filter(|e| {
|
||||
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
|
||||
})
|
||||
.filter(|e| match parsed_type {
|
||||
Some(t) => e.r#type == t,
|
||||
None => true,
|
||||
})
|
||||
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
if entries.is_empty() {
|
||||
eprintln!("(no items match)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||
for e in entries {
|
||||
let fav = if e.favorite { " *" } else { "" };
|
||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_history(query: String, show: bool, field: Option<String>) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
println!("History for {} ({})", item.title, item.id.as_str());
|
||||
println!();
|
||||
|
||||
// Filter and sort the field-id keys so output is deterministic.
|
||||
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut printed_any = false;
|
||||
for fid in keys {
|
||||
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
||||
if let Some(filter) = &field {
|
||||
if display_name != filter && fid.0 != *filter { continue; }
|
||||
}
|
||||
let entries = &item.field_history[fid];
|
||||
if entries.is_empty() { continue; }
|
||||
printed_any = true;
|
||||
|
||||
println!("{display_name} ({} {})",
|
||||
entries.len(),
|
||||
if entries.len() == 1 { "entry" } else { "entries" });
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let ts = crate::helpers::iso8601(e.replaced_at);
|
||||
if show {
|
||||
println!(" [{i}] {ts} {}", e.value.as_str());
|
||||
} else {
|
||||
println!(" [{i}] {ts} ********");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !printed_any {
|
||||
if field.is_some() {
|
||||
println!("no history for the requested field");
|
||||
} else {
|
||||
println!("no history captured for this item");
|
||||
}
|
||||
} else if !show {
|
||||
println!("(use --show to reveal values)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
60
crates/relicario-cli/src/commands/mod.rs
Normal file
60
crates/relicario-cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Per-command modules — one file per top-level subcommand.
|
||||
//!
|
||||
//! `main.rs` holds the clap surface (argument enums) and the dispatch
|
||||
//! `match`; the actual command bodies live here. Helpers shared between
|
||||
//! command modules (e.g. `commit_paths`, `resolve_query`) are defined in
|
||||
//! this file as `pub(crate)` so siblings can pull them in via
|
||||
//! `use crate::commands::*`.
|
||||
|
||||
pub mod add;
|
||||
pub mod attach;
|
||||
pub mod backup;
|
||||
pub mod device;
|
||||
pub mod edit;
|
||||
pub mod generate;
|
||||
pub mod get;
|
||||
pub mod import;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
pub mod rate;
|
||||
pub mod recovery_qr;
|
||||
pub mod settings;
|
||||
pub mod status;
|
||||
pub mod sync;
|
||||
pub mod trash;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(crate) fn commit_paths(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
message: &str,
|
||||
paths: &[&str],
|
||||
) -> Result<()> {
|
||||
let mut args: Vec<&str> = vec!["add"];
|
||||
args.extend_from_slice(paths);
|
||||
crate::helpers::git_run(vault.root(), &args, &format!("commit \"{message}\": git add"))?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", message],
|
||||
&format!("commit \"{message}\": git commit"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_query<'a>(
|
||||
manifest: &'a relicario_core::Manifest,
|
||||
query: &str,
|
||||
) -> Result<&'a relicario_core::ManifestEntry> {
|
||||
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
|
||||
return Ok(entry);
|
||||
}
|
||||
let hits: Vec<_> = manifest.search(query);
|
||||
match hits.len() {
|
||||
0 => anyhow::bail!("no item matches `{query}`"),
|
||||
1 => Ok(hits[0]),
|
||||
_ => {
|
||||
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
|
||||
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
28
crates/relicario-cli/src/commands/rate.rs
Normal file
28
crates/relicario-cli/src/commands/rate.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! `relicario rate` — score a passphrase via zxcvbn.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_rate(passphrase: String) -> Result<()> {
|
||||
let pw: String = if passphrase == "-" {
|
||||
use std::io::BufRead;
|
||||
let stdin = std::io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line)?;
|
||||
line.trim_end_matches(&['\r', '\n'][..]).to_string()
|
||||
} else {
|
||||
passphrase
|
||||
};
|
||||
let est = relicario_core::generators::rate_passphrase(&pw);
|
||||
let label = match est.score {
|
||||
0 => "very weak",
|
||||
1 => "weak",
|
||||
2 => "fair",
|
||||
3 => "good",
|
||||
4 => "strong",
|
||||
_ => "?",
|
||||
};
|
||||
println!("score: {}/4 ({})", est.score, label);
|
||||
println!("guesses: ~10^{:.1}", est.guesses_log10);
|
||||
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||
Ok(())
|
||||
}
|
||||
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! `relicario recovery-qr {generate,unwrap}` — last-resort vault-key escape hatch.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::RecoveryQrCmd;
|
||||
|
||||
pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
|
||||
match cmd {
|
||||
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
|
||||
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_generate() -> Result<()> {
|
||||
use relicario_core::{generate_recovery_qr, imgsecret};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let image_path = crate::session::get_image_path()?;
|
||||
let image_bytes = std::fs::read(&image_path)
|
||||
.with_context(|| format!("read reference image {}", image_path.display()))?;
|
||||
let image_secret = imgsecret::extract(&image_bytes)
|
||||
.context("extract image secret")?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter vault passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
use qrcode::{EcLevel, QrCode, render::unicode};
|
||||
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
|
||||
.expect("valid payload");
|
||||
let image = code
|
||||
.render::<unicode::Dense1x2>()
|
||||
.dark_color(unicode::Dense1x2::Dark)
|
||||
.light_color(unicode::Dense1x2::Light)
|
||||
.build();
|
||||
println!("{image}");
|
||||
println!("Recovery QR generated. Print or photograph this code and store it securely.");
|
||||
println!("The QR has NOT been saved to disk.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_unwrap() -> Result<()> {
|
||||
use relicario_core::unwrap_recovery_qr;
|
||||
use std::io::BufRead;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
println!("Paste the base64 recovery QR payload and press Enter:");
|
||||
let stdin = std::io::stdin();
|
||||
let payload_b64 = stdin.lock().lines().next()
|
||||
.context("no input")??;
|
||||
let payload_b64 = payload_b64.trim().to_owned();
|
||||
|
||||
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!("image_secret: {}", hex::encode(secret.as_ref()));
|
||||
Ok(())
|
||||
}
|
||||
98
crates/relicario-cli/src/commands/settings.rs
Normal file
98
crates/relicario-cli/src/commands/settings.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! `relicario settings {show, trash-retention, history-retention, attachment-cap, generator-defaults}`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::SettingsAction;
|
||||
|
||||
pub fn cmd_settings(action: SettingsAction) -> Result<()> {
|
||||
use relicario_core::{
|
||||
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||
SymbolCharset, TrashRetention,
|
||||
};
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut settings = vault.load_settings()?;
|
||||
|
||||
match action {
|
||||
SettingsAction::Show => {
|
||||
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||
return Ok(());
|
||||
}
|
||||
SettingsAction::TrashRetention { days, forever } => {
|
||||
settings.trash_retention = match (days, forever) {
|
||||
(Some(d), false) => TrashRetention::Days(d),
|
||||
(None, true) => TrashRetention::Forever,
|
||||
_ => anyhow::bail!("specify exactly one of --days or --forever"),
|
||||
};
|
||||
}
|
||||
SettingsAction::HistoryRetention { last_n, days, forever } => {
|
||||
settings.field_history_retention = match (last_n, days, forever) {
|
||||
(Some(n), None, false) => HistoryRetention::LastN(n),
|
||||
(None, Some(d), false) => HistoryRetention::Days(d),
|
||||
(None, None, true) => HistoryRetention::Forever,
|
||||
_ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
|
||||
};
|
||||
}
|
||||
SettingsAction::AttachmentCap {
|
||||
per_attachment_max_bytes, per_item_max_count,
|
||||
per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
|
||||
} => {
|
||||
if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
|
||||
if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
|
||||
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
|
||||
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
|
||||
}
|
||||
SettingsAction::GeneratorDefaults {
|
||||
random, bip39, length, words, symbols, separator,
|
||||
} => {
|
||||
// Decide target mode: explicit flag wins, else preserve current.
|
||||
let target_bip39 = if random { false }
|
||||
else if bip39 { true }
|
||||
else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };
|
||||
|
||||
// Pull existing fields where compatible, else seed with sensible
|
||||
// defaults (kept in sync with `GeneratorRequest::default()`).
|
||||
let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
|
||||
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||
(*length, *classes, symbol_charset.clone())
|
||||
}
|
||||
_ => (
|
||||
20,
|
||||
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
SymbolCharset::SafeOnly,
|
||||
),
|
||||
};
|
||||
let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
|
||||
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||
(*word_count, separator.clone(), *capitalization)
|
||||
}
|
||||
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||
};
|
||||
|
||||
settings.generator_defaults = if target_bip39 {
|
||||
GeneratorRequest::Bip39 {
|
||||
word_count: words.unwrap_or(cur_words),
|
||||
separator: separator.unwrap_or(cur_sep),
|
||||
capitalization: cur_cap,
|
||||
}
|
||||
} else {
|
||||
let charset = match symbols.as_deref() {
|
||||
None => cur_charset,
|
||||
Some("safe") => SymbolCharset::SafeOnly,
|
||||
Some("extended") => SymbolCharset::Extended,
|
||||
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||
};
|
||||
GeneratorRequest::Random {
|
||||
length: length.unwrap_or(cur_length),
|
||||
classes: cur_classes,
|
||||
symbol_charset: charset,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
vault.save_settings(&settings)?;
|
||||
super::commit_paths(&vault, "settings: update", &["settings.enc"])?;
|
||||
eprintln!("Settings updated.");
|
||||
Ok(())
|
||||
}
|
||||
52
crates/relicario-cli/src/commands/status.rs
Normal file
52
crates/relicario-cli/src/commands/status.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! `relicario status` — vault-level summary (counts, last commit, last backup).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_status() -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let root = vault.root().to_path_buf();
|
||||
let manifest = vault.load_manifest()?;
|
||||
|
||||
let total_items = manifest.items.len();
|
||||
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
||||
let active_items = total_items - trashed_items;
|
||||
|
||||
let (attachment_count, attachment_bytes) = manifest.items.values()
|
||||
.flat_map(|e| e.attachment_summaries.iter())
|
||||
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||
|
||||
let last_commit = crate::helpers::git_command(&root, &[
|
||||
"log", "-1", "--pretty=format:%h %s",
|
||||
]).output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "(no commits)".into());
|
||||
|
||||
// Last backup age (read from marker written by cmd_backup_export).
|
||||
let last_backup_path = vault.root().join(".relicario").join("last_backup");
|
||||
let last_backup_str = if last_backup_path.exists() {
|
||||
let line = std::fs::read_to_string(&last_backup_path)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
// Parse the ISO-8601 we wrote in cmd_backup_export.
|
||||
match chrono::DateTime::parse_from_rfc3339(&line) {
|
||||
Ok(then) => {
|
||||
let now = relicario_core::now_unix();
|
||||
let age = now - then.timestamp();
|
||||
crate::helpers::humanize_age(age.max(0))
|
||||
}
|
||||
Err(_) => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
"never".to_string()
|
||||
};
|
||||
|
||||
println!("Vault: {}", root.display());
|
||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||
println!("Last commit: {last_commit}");
|
||||
println!("Last export: {last_backup_str}");
|
||||
Ok(())
|
||||
}
|
||||
11
crates/relicario-cli/src/commands/sync.rs
Normal file
11
crates/relicario-cli/src/commands/sync.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! `relicario sync` — pull --rebase + push.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_sync() -> Result<()> {
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?;
|
||||
crate::helpers::git_run(&root, &["push"], "sync: git push")?;
|
||||
eprintln!("Sync complete.");
|
||||
Ok(())
|
||||
}
|
||||
149
crates/relicario-cli/src/commands/trash.rs
Normal file
149
crates/relicario-cli/src/commands/trash.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
//! Trash umbrella: `rm` (soft-delete), `restore`, `purge` (permanent),
|
||||
//! `trash list` / `trash empty`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::TrashAction;
|
||||
|
||||
pub fn cmd_rm(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
item.soft_delete();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Moved to trash: {}", item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_restore(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
item.restore();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Restored: {}", item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filesystem-only purge: removes the item.enc, attachments/<id>/, and updates
|
||||
/// the manifest in memory. Returns the relative paths the caller must stage
|
||||
/// via `git rm` after the loop. Does NOT invoke any git commands — the caller
|
||||
/// batches them.
|
||||
pub(super) fn purge_item_filesystem(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
manifest: &mut relicario_core::Manifest,
|
||||
id: &relicario_core::ItemId,
|
||||
title: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
use std::{fs, io::ErrorKind};
|
||||
|
||||
let item_rel = format!("items/{}.enc", id.as_str());
|
||||
let att_rel = format!("attachments/{}", id.as_str());
|
||||
|
||||
let ignore_missing = |r: std::io::Result<()>| -> Result<()> {
|
||||
match r {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
};
|
||||
ignore_missing(fs::remove_file(vault.item_path(id)))?;
|
||||
ignore_missing(fs::remove_dir_all(vault.root().join("attachments").join(id.as_str())))?;
|
||||
manifest.remove(id);
|
||||
|
||||
eprintln!("Purged: {title}");
|
||||
Ok(vec![item_rel, att_rel])
|
||||
}
|
||||
|
||||
pub fn cmd_purge(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let title = entry.title.clone();
|
||||
let _ = entry;
|
||||
|
||||
let paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
||||
crate::helpers::git_rm(vault.root(), &paths, &format!("{purge_ctx}: git rm"))?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["add", "manifest.enc"],
|
||||
&format!("{purge_ctx}: git add manifest.enc"),
|
||||
)?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
|
||||
&format!("{purge_ctx}: git commit"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_trash(action: TrashAction) -> Result<()> {
|
||||
match action {
|
||||
TrashAction::List => super::list::cmd_list(None, None, None, true),
|
||||
TrashAction::Empty => cmd_trash_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_trash_empty() -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let settings = vault.load_settings()?;
|
||||
let now = now_unix();
|
||||
|
||||
let purgeable: Vec<_> = manifest.items.values()
|
||||
.filter(|e| match e.trashed_at {
|
||||
Some(t) => settings.trash_retention.should_purge(t, now),
|
||||
None => false,
|
||||
})
|
||||
.map(|e| (e.id.clone(), e.title.clone()))
|
||||
.collect();
|
||||
|
||||
if purgeable.is_empty() {
|
||||
eprintln!("nothing past retention window");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut all_paths: Vec<String> = Vec::new();
|
||||
let purged_count = purgeable.len();
|
||||
for (id, title) in purgeable {
|
||||
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||
all_paths.append(&mut paths);
|
||||
}
|
||||
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
crate::helpers::git_rm(vault.root(), &all_paths, "trash empty: git rm")?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["add", "manifest.enc"],
|
||||
"trash empty: git add manifest.enc",
|
||||
)?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
|
||||
"trash empty: git commit",
|
||||
)?;
|
||||
|
||||
eprintln!("Emptied trash: {} item(s)", purged_count);
|
||||
Ok(())
|
||||
}
|
||||
169
crates/relicario-cli/src/device.rs
Normal file
169
crates/relicario-cli/src/device.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! Local device key storage and git signing configuration.
|
||||
//!
|
||||
//! Keys live under `~/.config/relicario/devices/<device-name>/`:
|
||||
//! signing.key — ed25519 private key (OpenSSH, 0600)
|
||||
//! signing.pub — ed25519 public key (OpenSSH single line)
|
||||
//! deploy.key — ed25519 private key for git push (OpenSSH, 0600)
|
||||
//! deploy.pub — ed25519 public key registered as Gitea deploy key
|
||||
//! gitea_key_id — numeric Gitea deploy key ID for later revocation
|
||||
//!
|
||||
//! The file `~/.config/relicario/devices/current` holds the active device name
|
||||
//! (one plain-text line).
|
||||
|
||||
use std::fs::{self, Permissions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// `~/.config/relicario/devices/`
|
||||
pub fn devices_dir() -> Result<PathBuf> {
|
||||
let config = dirs::config_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("no config directory available"))?;
|
||||
Ok(config.join("relicario").join("devices"))
|
||||
}
|
||||
|
||||
/// `~/.config/relicario/devices/<name>/`
|
||||
pub fn device_dir(name: &str) -> Result<PathBuf> {
|
||||
Ok(devices_dir()?.join(name))
|
||||
}
|
||||
|
||||
/// Read the current device name from `devices/current`, or `None` if not set.
|
||||
pub fn current_device() -> Result<Option<String>> {
|
||||
let path = devices_dir()?.join("current");
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = fs::read_to_string(&path)
|
||||
.context("read current device")?
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the active device name to `devices/current`.
|
||||
pub fn set_current_device(name: &str) -> Result<()> {
|
||||
let dir = devices_dir()?;
|
||||
fs::create_dir_all(&dir).context("create devices dir")?;
|
||||
fs::write(dir.join("current"), format!("{name}\n"))
|
||||
.context("write current device")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store all keys for a device, applying restrictive permissions on private
|
||||
/// key files on Unix.
|
||||
pub fn store_device_keys(
|
||||
name: &str,
|
||||
signing_private: &str,
|
||||
signing_public: &str,
|
||||
deploy_private: &str,
|
||||
deploy_public: &str,
|
||||
gitea_key_id: u64,
|
||||
) -> Result<()> {
|
||||
let dir = device_dir(name)?;
|
||||
fs::create_dir_all(&dir).context("create device dir")?;
|
||||
|
||||
fs::write(dir.join("signing.key"), signing_private)
|
||||
.context("write signing.key")?;
|
||||
fs::write(dir.join("signing.pub"), signing_public)
|
||||
.context("write signing.pub")?;
|
||||
fs::write(dir.join("deploy.key"), deploy_private)
|
||||
.context("write deploy.key")?;
|
||||
fs::write(dir.join("deploy.pub"), deploy_public)
|
||||
.context("write deploy.pub")?;
|
||||
fs::write(dir.join("gitea_key_id"), gitea_key_id.to_string())
|
||||
.context("write gitea_key_id")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(dir.join("signing.key"), Permissions::from_mode(0o600))
|
||||
.context("chmod signing.key")?;
|
||||
fs::set_permissions(dir.join("deploy.key"), Permissions::from_mode(0o600))
|
||||
.context("chmod deploy.key")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the signing private key for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
let path = device_dir(name)?.join("signing.key");
|
||||
let key = fs::read_to_string(&path)
|
||||
.with_context(|| format!("read signing key for device '{name}'"))?;
|
||||
Ok(Zeroizing::new(key))
|
||||
}
|
||||
|
||||
/// Load the deploy private key for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
let path = device_dir(name)?.join("deploy.key");
|
||||
let key = fs::read_to_string(&path)
|
||||
.with_context(|| format!("read deploy key for device '{name}'"))?;
|
||||
Ok(Zeroizing::new(key))
|
||||
}
|
||||
|
||||
/// Load the Gitea deploy key ID for a device.
|
||||
pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
||||
let path = device_dir(name)?.join("gitea_key_id");
|
||||
let id_str = fs::read_to_string(&path)
|
||||
.with_context(|| format!("read Gitea key ID for device '{name}'"))?;
|
||||
id_str.trim().parse().context("parse Gitea key ID")
|
||||
}
|
||||
|
||||
/// Delete the local key directory for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||
let dir = device_dir(name)?;
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)
|
||||
.with_context(|| format!("delete device dir for '{name}'"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure git in `vault_root` to:
|
||||
/// - sign commits with the device's signing key (SSH format)
|
||||
/// - push via SSH using the device's deploy key
|
||||
pub fn configure_git_signing(vault_root: &std::path::Path, name: &str) -> Result<()> {
|
||||
let dir = device_dir(name)?;
|
||||
let signing_key = dir.join("signing.key");
|
||||
let deploy_key = dir.join("deploy.key");
|
||||
|
||||
// gpg.format = ssh so git uses SSH-format signing
|
||||
crate::helpers::git_command(vault_root, &["config", "gpg.format", "ssh"])
|
||||
.status()
|
||||
.context("git config gpg.format")?;
|
||||
|
||||
// user.signingkey = path to the private key file
|
||||
crate::helpers::git_command(
|
||||
vault_root,
|
||||
&["config", "user.signingkey", &signing_key.to_string_lossy()],
|
||||
)
|
||||
.status()
|
||||
.context("git config user.signingkey")?;
|
||||
|
||||
// commit.gpgsign = true
|
||||
crate::helpers::git_command(vault_root, &["config", "commit.gpgsign", "true"])
|
||||
.status()
|
||||
.context("git config commit.gpgsign")?;
|
||||
|
||||
// core.sshCommand — use only the deploy key for push
|
||||
let ssh_cmd = format!(
|
||||
"ssh -i {} -o IdentitiesOnly=yes",
|
||||
deploy_key.display()
|
||||
);
|
||||
crate::helpers::git_command(
|
||||
vault_root,
|
||||
&["config", "core.sshCommand", &ssh_cmd],
|
||||
)
|
||||
.status()
|
||||
.context("git config core.sshCommand")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
117
crates/relicario-cli/src/gitea.rs
Normal file
117
crates/relicario-cli/src/gitea.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Gitea API client for deploy key management.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GiteaClient {
|
||||
api_url: String,
|
||||
token: String,
|
||||
owner: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateKeyRequest<'a> {
|
||||
title: &'a str,
|
||||
key: &'a str,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeployKey {
|
||||
pub id: u64,
|
||||
#[allow(dead_code)]
|
||||
pub title: String,
|
||||
#[allow(dead_code)]
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl GiteaClient {
|
||||
pub fn new(api_url: &str, token: &str, owner: &str, repo: &str) -> Self {
|
||||
Self {
|
||||
api_url: api_url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a deploy key, returning its ID.
|
||||
pub fn create_deploy_key(&self, title: &str, public_key: &str) -> Result<u64> {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/keys",
|
||||
self.api_url, self.owner, self.repo
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&CreateKeyRequest {
|
||||
title,
|
||||
key: public_key,
|
||||
read_only: false,
|
||||
})
|
||||
.send()
|
||||
.context("Gitea API request failed")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||
}
|
||||
|
||||
let key: DeployKey = resp.json().context("parse deploy key response")?;
|
||||
Ok(key.id)
|
||||
}
|
||||
|
||||
/// Delete a deploy key by ID.
|
||||
pub fn delete_deploy_key(&self, key_id: u64) -> Result<()> {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/keys/{}",
|
||||
self.api_url, self.owner, self.repo, key_id
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.send()
|
||||
.context("Gitea API request failed")?;
|
||||
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all deploy keys.
|
||||
#[allow(dead_code)]
|
||||
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/keys",
|
||||
self.api_url, self.owner, self.repo
|
||||
);
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.send()
|
||||
.context("Gitea API request failed")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||
}
|
||||
|
||||
let keys: Vec<DeployKey> = resp.json().context("parse deploy keys response")?;
|
||||
Ok(keys)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub fn vault_dir() -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
/// Path to the `.relicario/` configuration directory within the vault.
|
||||
#[allow(dead_code)]
|
||||
pub fn relicario_dir() -> Result<PathBuf> {
|
||||
Ok(vault_dir()?.join(".relicario"))
|
||||
}
|
||||
@@ -54,6 +55,47 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run `git <args>` in `repo` with the same hardening as `git_command`,
|
||||
/// capturing stdout/stderr and reproducing them on failure so the caller
|
||||
/// sees git's exact diagnostic instead of just a verb.
|
||||
///
|
||||
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
|
||||
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
|
||||
/// identifiable from the error alone.
|
||||
///
|
||||
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
|
||||
/// (so live progress disappears during long-running fetches/pushes) but the
|
||||
/// captured chunk is replayed verbatim on failure. The win is that
|
||||
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
|
||||
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
|
||||
/// instead of one-line "git X failed" bails. Use `git_command` directly when
|
||||
/// live streaming is required.
|
||||
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||
let output = git_command(repo, args)
|
||||
.output()
|
||||
.with_context(|| format!("{context}: failed to spawn git"))?;
|
||||
if !output.status.success() {
|
||||
if !output.stdout.is_empty() {
|
||||
eprint!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
if !output.stderr.is_empty() {
|
||||
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
bail!("{context}: git failed ({})", output.status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
|
||||
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
|
||||
/// leave the manifest entry without the corresponding `items/<id>.enc` on
|
||||
/// disk, and we want the rm to succeed regardless.
|
||||
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
|
||||
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
|
||||
args.extend(paths.iter().map(String::as_str));
|
||||
git_run(repo, &args, context)
|
||||
}
|
||||
|
||||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||
/// a numeric string.
|
||||
@@ -63,6 +105,121 @@ pub fn iso8601(unix_seconds: i64) -> String {
|
||||
.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. In debug builds, 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")
|
||||
}
|
||||
|
||||
/// Collect all non-empty group names from the manifest and write them to the
|
||||
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
||||
/// candidates without prompting for the vault passphrase.
|
||||
///
|
||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||
/// not a correctness problem.
|
||||
pub fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
||||
let mut set = std::collections::BTreeSet::<String>::new();
|
||||
for entry in manifest.items.values() {
|
||||
if let Some(g) = entry.group.as_ref() {
|
||||
if !g.is_empty() {
|
||||
set.insert(g.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = write_groups_cache(vault_dir, &set);
|
||||
}
|
||||
|
||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||
/// suppresses the write (developer debugging tool). In release builds the env
|
||||
/// var is ignored.
|
||||
pub fn write_groups_cache(
|
||||
vault_dir: &Path,
|
||||
groups: &std::collections::BTreeSet<String>,
|
||||
) -> std::io::Result<()> {
|
||||
if cfg!(debug_assertions) && 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)
|
||||
}
|
||||
|
||||
/// Sanitize a string for use in a git commit message subject line.
|
||||
///
|
||||
/// Removes all Unicode control characters (U+0000–U+001F, U+007F, and higher
|
||||
/// control planes) so that newlines and escape sequences cannot corrupt `git
|
||||
/// log` output. Truncates to 50 characters so the subject line stays within
|
||||
/// the conventional limit.
|
||||
///
|
||||
/// Audit I1: item titles are user-supplied and may contain arbitrary bytes.
|
||||
pub fn sanitize_for_commit(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(50)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -98,4 +255,62 @@ mod tests {
|
||||
// 2026-04-19T00:00:00Z = 1776556800
|
||||
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_for_commit_strips_control_chars() {
|
||||
assert_eq!(sanitize_for_commit("line1\nline2"), "line1line2");
|
||||
assert_eq!(sanitize_for_commit("a\tb"), "ab");
|
||||
assert_eq!(sanitize_for_commit("normal"), "normal");
|
||||
assert_eq!(sanitize_for_commit("cr\r\nline"), "crline");
|
||||
// ESC (U+001B) is control and gets stripped; bracket sequences are printable
|
||||
assert_eq!(sanitize_for_commit("\x1b[31mred\x1b[0m"), "[31mred[0m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_for_commit_truncates_to_50() {
|
||||
let long = "a".repeat(60);
|
||||
assert_eq!(sanitize_for_commit(&long).len(), 50);
|
||||
assert_eq!(sanitize_for_commit(&long), "a".repeat(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_for_commit_allows_unicode() {
|
||||
assert_eq!(sanitize_for_commit("cafe\u{0301}"), "cafe\u{0301}");
|
||||
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_run_bails_with_context_on_failure() {
|
||||
// Empty tempdir — `git status` will fail with "not a git repository".
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("test_ctx"), "context not in error: {msg}");
|
||||
assert!(msg.contains("git failed"), "missing failure marker: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_run_succeeds_for_a_zero_exit_command() {
|
||||
// `git --version` always succeeds and is independent of cwd.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
git_run(tmp.path(), &["--version"], "version probe")
|
||||
.expect("git --version should succeed");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
47
crates/relicario-cli/src/parse.rs
Normal file
47
crates/relicario-cli/src/parse.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess).
|
||||
//!
|
||||
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and
|
||||
//! turns this file into a thin re-export shim. They live here for now so
|
||||
//! the Phase 1 relocation stays mechanical.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||
let month: u8 = m_str.parse().context("invalid month")?;
|
||||
let year: u16 = if y_str.len() == 2 {
|
||||
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
|
||||
} else {
|
||||
y_str.parse().context("invalid year")?
|
||||
};
|
||||
Ok(relicario_core::MonthYear { month, year })
|
||||
}
|
||||
|
||||
pub(crate) fn guess_mime(filename: &str) -> String {
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
|
||||
"pdf" => "application/pdf",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"txt" => "text/plain",
|
||||
"json" => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
|
||||
let cleaned: String = s.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.to_ascii_uppercase()
|
||||
.trim_end_matches('=')
|
||||
.to_string();
|
||||
let padded = {
|
||||
let rem = cleaned.len() % 8;
|
||||
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
|
||||
};
|
||||
data_encoding::BASE32.decode(padded.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
|
||||
}
|
||||
65
crates/relicario-cli/src/prompt.rs
Normal file
65
crates/relicario-cli/src/prompt.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Interactive prompt helpers for the CLI.
|
||||
//!
|
||||
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
|
||||
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
||||
//! used by the edit handlers to keep current values when the user hits enter
|
||||
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
||||
//! so integration tests (which don't have a TTY) can inject secrets.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||
/// unavailable in assert_cmd-spawned children).
|
||||
pub(crate) fn prompt_secret(label: &str) -> Result<String> {
|
||||
if let Some(s) = crate::test_item_secret_override() {
|
||||
return Ok(s);
|
||||
}
|
||||
rpassword::prompt_password(label).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||
eprint!("{label}: ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||
eprint!("{label} (leave blank to skip): ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim().to_string();
|
||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||
eprint!("{label} [{current}]: ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim().to_string();
|
||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
|
||||
let display = current.unwrap_or("(none)");
|
||||
eprint!("{label} [{display}]: ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
let trimmed = s.trim().to_string();
|
||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
|
||||
eprint!("{label} [y/N] ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
||||
}
|
||||
@@ -39,7 +39,7 @@ impl UnlockedVault {
|
||||
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
||||
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
||||
|
||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
|
||||
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(
|
||||
@@ -50,7 +50,7 @@ impl UnlockedVault {
|
||||
|
||||
let master_key = derive_master_key(
|
||||
passphrase.as_bytes(),
|
||||
&*image_secret,
|
||||
&image_secret,
|
||||
&salt,
|
||||
¶ms,
|
||||
)?;
|
||||
@@ -69,9 +69,15 @@ impl UnlockedVault {
|
||||
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
|
||||
/// Save the manifest and refresh the plaintext groups.cache. This is the
|
||||
/// canonical "I just mutated the manifest" funnel — every command that
|
||||
/// changes the manifest goes through this method, so cache freshness is
|
||||
/// a compile-time invariant rather than a discipline rule.
|
||||
pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
|
||||
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||
atomic_write(&self.manifest_path(), &bytes)
|
||||
atomic_write(&self.manifest_path(), &bytes)?;
|
||||
crate::helpers::refresh_groups_cache(&self.root, manifest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||
@@ -107,17 +113,52 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ParamsFile {
|
||||
kdf: KdfParams,
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct ParamsFile {
|
||||
pub format_version: u32,
|
||||
pub kdf: ParamsKdf,
|
||||
pub aead: String,
|
||||
pub salt_path: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) struct ParamsKdf {
|
||||
pub algorithm: String,
|
||||
pub argon2_m: u32,
|
||||
pub argon2_t: u32,
|
||||
pub argon2_p: u32,
|
||||
}
|
||||
|
||||
impl ParamsFile {
|
||||
pub fn for_new_vault(params: &KdfParams) -> Self {
|
||||
Self {
|
||||
format_version: 2,
|
||||
kdf: ParamsKdf {
|
||||
algorithm: "argon2id-v0x13".into(),
|
||||
argon2_m: params.argon2_m,
|
||||
argon2_t: params.argon2_t,
|
||||
argon2_p: params.argon2_p,
|
||||
},
|
||||
aead: "xchacha20poly1305".into(),
|
||||
salt_path: ".relicario/salt".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_kdf_params(&self) -> KdfParams {
|
||||
KdfParams {
|
||||
argon2_m: self.kdf.argon2_m,
|
||||
argon2_t: self.kdf.argon2_t,
|
||||
argon2_p: self.kdf.argon2_p,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||
.context("failed to read .relicario/params.json")?;
|
||||
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||
Ok(pf.kdf)
|
||||
Ok(pf.to_kdf_params())
|
||||
}
|
||||
|
||||
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||
@@ -149,3 +190,78 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FIXTURE: &str = r#"{
|
||||
"format_version": 2,
|
||||
"kdf": {
|
||||
"algorithm": "argon2id-v0x13",
|
||||
"argon2_m": 65536,
|
||||
"argon2_t": 3,
|
||||
"argon2_p": 4
|
||||
},
|
||||
"aead": "xchacha20poly1305",
|
||||
"salt_path": ".relicario/salt"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn params_file_round_trips_current_layout() {
|
||||
let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
|
||||
assert_eq!(pf.format_version, 2);
|
||||
assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
|
||||
assert_eq!(pf.kdf.argon2_m, 65536);
|
||||
assert_eq!(pf.kdf.argon2_t, 3);
|
||||
assert_eq!(pf.kdf.argon2_p, 4);
|
||||
assert_eq!(pf.aead, "xchacha20poly1305");
|
||||
assert_eq!(pf.salt_path, ".relicario/salt");
|
||||
|
||||
let kdf = pf.to_kdf_params();
|
||||
assert_eq!(kdf.argon2_m, 65536);
|
||||
assert_eq!(kdf.argon2_t, 3);
|
||||
assert_eq!(kdf.argon2_p, 4);
|
||||
|
||||
let serialized = serde_json::to_string(&pf).expect("re-serialize");
|
||||
let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
|
||||
assert_eq!(pf2.format_version, 2);
|
||||
assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
|
||||
assert_eq!(pf2.kdf.argon2_m, 65536);
|
||||
assert_eq!(pf2.kdf.argon2_t, 3);
|
||||
assert_eq!(pf2.kdf.argon2_p, 4);
|
||||
assert_eq!(pf2.aead, "xchacha20poly1305");
|
||||
assert_eq!(pf2.salt_path, ".relicario/salt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_new_vault_produces_expected_shape() {
|
||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
let pf = ParamsFile::for_new_vault(¶ms);
|
||||
let v = serde_json::to_value(&pf).expect("to_value");
|
||||
assert_eq!(v["format_version"], 2);
|
||||
assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
|
||||
assert_eq!(v["kdf"]["argon2_m"], 65536);
|
||||
assert_eq!(v["kdf"]["argon2_t"], 3);
|
||||
assert_eq!(v["kdf"]["argon2_p"], 4);
|
||||
assert_eq!(v["aead"], "xchacha20poly1305");
|
||||
assert_eq!(v["salt_path"], ".relicario/salt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_manifest_change_writes_manifest_and_groups_cache() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let root = dir.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join(".relicario")).unwrap();
|
||||
std::fs::create_dir_all(root.join("items")).unwrap();
|
||||
let vault = UnlockedVault {
|
||||
root: root.clone(),
|
||||
master_key: Zeroizing::new([0u8; 32]),
|
||||
};
|
||||
let manifest = Manifest::new();
|
||||
|
||||
vault.after_manifest_change(&manifest).unwrap();
|
||||
assert!(root.join("manifest.enc").exists());
|
||||
assert!(root.join(".relicario/groups.cache").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,67 @@ fn attach_list_extract_round_trip() {
|
||||
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("");
|
||||
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]
|
||||
fn attach_rejects_over_cap() {
|
||||
let v = TestVault::init();
|
||||
|
||||
142
crates/relicario-cli/tests/backup.rs
Normal file
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}");
|
||||
}
|
||||
@@ -8,7 +8,8 @@ fn init_creates_expected_layout() {
|
||||
let v = TestVault::init();
|
||||
assert!(v.path().join(".relicario/salt").exists());
|
||||
assert!(v.path().join(".relicario/params.json").exists());
|
||||
assert!(v.path().join(".relicario/devices.json").exists());
|
||||
// devices.json removed — device key system was security theater
|
||||
assert!(!v.path().join(".relicario/devices.json").exists());
|
||||
assert!(v.path().join("manifest.enc").exists());
|
||||
assert!(v.path().join("settings.enc").exists());
|
||||
assert!(v.path().join("reference.jpg").exists());
|
||||
@@ -108,6 +109,72 @@ fn rm_restore_purge_cycle() {
|
||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_empty_batches_into_one_commit() {
|
||||
let v = TestVault::init();
|
||||
|
||||
// Add 3 items.
|
||||
for title in ["alpha", "bravo", "charlie"] {
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", title,
|
||||
"--username", "u",
|
||||
"--password", "p",
|
||||
]);
|
||||
assert!(out.status.success(), "add {title} failed");
|
||||
}
|
||||
|
||||
// Soft-delete all 3.
|
||||
for title in ["alpha", "bravo", "charlie"] {
|
||||
let out = v.run(&["rm", title]);
|
||||
assert!(out.status.success(), "rm {title} failed");
|
||||
}
|
||||
|
||||
// Set retention to 0 days so the recently-trashed items become purgeable
|
||||
// (should_purge: now - trashed_at > 0 * 86400 = 0).
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "0"]);
|
||||
assert!(out.status.success(), "settings trash-retention failed");
|
||||
|
||||
// should_purge uses strict > on (now - trashed_at), so equal-second
|
||||
// timestamps don't qualify.
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
|
||||
// Count commits before.
|
||||
let before = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.current_dir(v.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let before_count: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||
|
||||
// Run trash empty.
|
||||
let out = v.run(&["trash", "empty"]);
|
||||
assert!(out.status.success(), "trash empty failed: stderr={}",
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Count commits after.
|
||||
let after = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.current_dir(v.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let after_count: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
after_count - before_count, 1,
|
||||
"trash empty should fire exactly one commit; before={before_count} after={after_count}"
|
||||
);
|
||||
|
||||
// The remaining `list --trashed` should be empty.
|
||||
let out = v.run(&["list", "--trashed"]);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(
|
||||
!stdout.contains("alpha") && !stdout.contains("bravo") && !stdout.contains("charlie"),
|
||||
"items still in trashed list: stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_and_bip39() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -78,6 +78,21 @@ impl TestVault {
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
|
||||
@@ -57,3 +57,135 @@ fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::pro
|
||||
}
|
||||
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
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
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,138 @@ fn settings_rejects_conflicting_retention_flags() {
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
||||
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_and_attachment_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}");
|
||||
|
||||
// device count line removed — device key system was security theater (audit B1).
|
||||
|
||||
// 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
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
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]
|
||||
name = "relicario-core"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
|
||||
@@ -15,6 +15,7 @@ sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
hmac = "0.12"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
# Typed-item additions
|
||||
@@ -26,5 +27,10 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc
|
||||
hex = "0.4"
|
||||
url = { version = "2", features = ["serde"] }
|
||||
getrandom = "0.2"
|
||||
zstd = { version = "0.13", default-features = false }
|
||||
tar = { version = "0.4", default-features = false }
|
||||
base64 = "0.22"
|
||||
csv = "1"
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
348
crates/relicario-core/src/backup.rs
Normal file
348
crates/relicario-core/src/backup.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
//! 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]>> {
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
// NFC normalize passphrase (matches derive_master_key in crypto.rs)
|
||||
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
|
||||
Ok(s) => s.nfc().collect::<String>().into_bytes(),
|
||||
Err(_) => passphrase.to_vec(),
|
||||
};
|
||||
|
||||
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(&nfc_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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -243,6 +243,23 @@ pub fn derive_master_key(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
|
||||
/// allowing callers to apply their own domain separation before KDF.
|
||||
pub fn derive_master_key_raw(
|
||||
input: &[u8],
|
||||
salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
let mut output = Zeroizing::new([0u8; 32]);
|
||||
argon2
|
||||
.hash_password_into(input, salt, output.as_mut())
|
||||
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -408,7 +425,7 @@ mod tests {
|
||||
blob.extend_from_slice(&[0u8; 16]);
|
||||
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
|
||||
let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
|
||||
match err {
|
||||
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||
assert_eq!(found, 0x01);
|
||||
|
||||
168
crates/relicario-core/src/device.rs
Normal file
168
crates/relicario-core/src/device.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! Device identity: ed25519 keypairs in OpenSSH format, signing and verification.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ssh_key::{LineEnding, PrivateKey, PublicKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// A registered device entry in devices.json.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceEntry {
|
||||
pub name: String,
|
||||
/// OpenSSH public key format: "ssh-ed25519 AAAA..."
|
||||
pub public_key: String,
|
||||
pub added_at: i64,
|
||||
pub added_by: String,
|
||||
}
|
||||
|
||||
/// A revoked device entry in revoked.json.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RevokedEntry {
|
||||
pub name: String,
|
||||
pub public_key: String,
|
||||
pub revoked_at: i64,
|
||||
pub revoked_by: String,
|
||||
}
|
||||
|
||||
/// Generate a new ed25519 keypair, returning (private_openssh, public_openssh).
|
||||
pub fn generate_keypair() -> Result<(Zeroizing<String>, String)> {
|
||||
use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData};
|
||||
use ssh_key::public::Ed25519PublicKey;
|
||||
|
||||
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
|
||||
// Build ssh-key types from raw bytes
|
||||
let ed_private = Ed25519PrivateKey::from_bytes(signing_key.as_bytes());
|
||||
let ed_public = Ed25519PublicKey(*verifying_key.as_bytes());
|
||||
let keypair = Ed25519Keypair { public: ed_public, private: ed_private };
|
||||
let keypair_data = KeypairData::Ed25519(keypair);
|
||||
|
||||
let ssh_private = PrivateKey::new(keypair_data, "")
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("private key create: {e}")))?;
|
||||
let ssh_public = ssh_private.public_key();
|
||||
|
||||
let private_pem = ssh_private
|
||||
.to_openssh(LineEnding::LF)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("private key encode: {e}")))?;
|
||||
let public_line = ssh_public
|
||||
.to_openssh()
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("public key encode: {e}")))?;
|
||||
|
||||
Ok((Zeroizing::new(private_pem.to_string()), public_line))
|
||||
}
|
||||
|
||||
/// Sign data with an OpenSSH private key, returning base64 signature.
|
||||
pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result<String> {
|
||||
use base64::Engine;
|
||||
|
||||
let private = PrivateKey::from_openssh(private_key_openssh)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?;
|
||||
|
||||
let key_data = private
|
||||
.key_data()
|
||||
.ed25519()
|
||||
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
|
||||
|
||||
let secret_slice: &[u8] = key_data.private.as_ref();
|
||||
let secret_bytes: [u8; 32] = secret_slice
|
||||
.try_into()
|
||||
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
|
||||
|
||||
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
||||
let signature = signing_key.sign(data);
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()))
|
||||
}
|
||||
|
||||
/// Verify a signature against an OpenSSH public key.
|
||||
pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Result<bool> {
|
||||
use base64::Engine;
|
||||
|
||||
let public = PublicKey::from_openssh(public_key_openssh)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||
|
||||
let key_data = public
|
||||
.key_data()
|
||||
.ed25519()
|
||||
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
|
||||
|
||||
let pub_slice: &[u8] = key_data.as_ref();
|
||||
let pub_bytes: [u8; 32] = pub_slice
|
||||
.try_into()
|
||||
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
|
||||
|
||||
let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("invalid public key: {e}")))?;
|
||||
|
||||
let sig_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(signature_b64)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("decode signature: {e}")))?;
|
||||
|
||||
let signature = Signature::from_slice(&sig_bytes)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("parse signature: {e}")))?;
|
||||
|
||||
Ok(verifying_key.verify(data, &signature).is_ok())
|
||||
}
|
||||
|
||||
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
|
||||
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
|
||||
/// `SHA256:<43-char base64 without padding>`.
|
||||
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
|
||||
use ssh_key::HashAlg;
|
||||
let public = PublicKey::from_openssh(public_key_openssh)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||
Ok(public.fingerprint(HashAlg::Sha256).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_and_sign_verify_roundtrip() {
|
||||
let (private, public) = generate_keypair().unwrap();
|
||||
let data = b"hello world";
|
||||
let sig = sign(&private, data).unwrap();
|
||||
assert!(verify(&public, data, &sig).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_data() {
|
||||
let (private, public) = generate_keypair().unwrap();
|
||||
let sig = sign(&private, b"hello").unwrap();
|
||||
assert!(!verify(&public, b"world", &sig).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_key() {
|
||||
let (private, _) = generate_keypair().unwrap();
|
||||
let (_, other_public) = generate_keypair().unwrap();
|
||||
let sig = sign(&private, b"hello").unwrap();
|
||||
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_matches_ssh_keygen_format() {
|
||||
let (_, public) = generate_keypair().unwrap();
|
||||
let fp = fingerprint(&public).unwrap();
|
||||
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
|
||||
let body = fp.strip_prefix("SHA256:").unwrap();
|
||||
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
|
||||
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_deterministic() {
|
||||
let (_, public) = generate_keypair().unwrap();
|
||||
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_differs_per_key() {
|
||||
let (_, p1) = generate_keypair().unwrap();
|
||||
let (_, p2) = generate_keypair().unwrap();
|
||||
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//! for `std::result::Result<T, RelicarioError>`. Using a single error enum keeps the
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
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:
|
||||
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
|
||||
@@ -39,6 +39,33 @@ pub enum RelicarioError {
|
||||
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
|
||||
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 },
|
||||
|
||||
/// An error during backup restore (e.g., tar safety validation failure).
|
||||
#[error("backup restore: {0}")]
|
||||
BackupRestore(String),
|
||||
|
||||
/// 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.
|
||||
#[error("item not found: {0}")]
|
||||
ItemNotFound(String),
|
||||
@@ -86,6 +113,16 @@ pub enum RelicarioError {
|
||||
/// rotating the passphrase or reference image.
|
||||
#[error("device key error: {0}")]
|
||||
DeviceKey(String),
|
||||
|
||||
/// HOTP requires incrementing and persisting the counter after each use.
|
||||
/// Without vault-save machinery in compute_totp_code, HOTP would desync
|
||||
/// immediately. Use TOTP instead.
|
||||
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
|
||||
HotpNotSupported,
|
||||
|
||||
/// Recovery QR generation or parsing failed.
|
||||
#[error("recovery QR: {0}")]
|
||||
RecoveryQr(String),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||
@@ -130,4 +167,29 @@ mod tests {
|
||||
assert!(s.contains("01") || s.contains("1"));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
|
||||
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
|
||||
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
|
||||
//! - `AttachmentId` is the first 32 hex chars of `sha256(plaintext)` (128 bits) —
|
||||
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
|
||||
//! (audit I2/B4: bumped from 8-byte/64-bit format to prevent birthday collisions)
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
@@ -29,6 +30,12 @@ impl ItemId {
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
|
||||
/// Returns true if this ID is valid for filesystem paths.
|
||||
/// Valid ItemIds are 16 lowercase hex chars.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ItemId {
|
||||
@@ -51,9 +58,15 @@ impl Default for FieldId {
|
||||
impl AttachmentId {
|
||||
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
||||
let digest = Sha256::digest(plaintext);
|
||||
Self(hex::encode(&digest[..8]))
|
||||
Self(hex::encode(&digest[..16])) // 16 bytes = 128 bits
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
|
||||
/// Returns true if this ID is valid for filesystem paths.
|
||||
/// Valid AttachmentIds are 32 lowercase hex chars.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -106,12 +119,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_is_16_hex_chars() {
|
||||
fn attachment_id_is_32_hex_chars() {
|
||||
let id = AttachmentId::from_plaintext(b"any bytes");
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_id_is_valid_for_normal_ids() {
|
||||
let id = ItemId::new();
|
||||
assert!(id.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_id_is_invalid_for_traversal() {
|
||||
let bad = ItemId("../../../etc".to_string());
|
||||
assert!(!bad.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_is_valid_for_normal_ids() {
|
||||
let id = AttachmentId::from_plaintext(b"test");
|
||||
assert!(id.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_id_is_invalid_for_traversal() {
|
||||
let bad = AttachmentId("../../passwd".to_string());
|
||||
assert!(!bad.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_serialize_as_bare_strings() {
|
||||
let item = ItemId("abcdef0123456789".to_string());
|
||||
|
||||
@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
||||
|
||||
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||
/// ceil(256 / 12) = 22 blocks per copy.
|
||||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
||||
const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
|
||||
|
||||
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||
return None;
|
||||
}
|
||||
let mut block = [[0.0f64; 8]; 8];
|
||||
for row in 0..8 {
|
||||
for col in 0..8 {
|
||||
block[row][col] = y.get(px + col, py + row);
|
||||
for (row, block_row) in block.iter_mut().enumerate() {
|
||||
for (col, cell) in block_row.iter_mut().enumerate() {
|
||||
*cell = y.get(px + col, py + row);
|
||||
}
|
||||
}
|
||||
Some(block)
|
||||
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
|
||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||
for row in 0..8 {
|
||||
for col in 0..8 {
|
||||
y.set(start_x + col, start_y + row, block[row][col]);
|
||||
for (row, block_row) in block.iter().enumerate() {
|
||||
for (col, &cell) in block_row.iter().enumerate() {
|
||||
y.set(start_x + col, start_y + row, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
|
||||
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for k in 0..8 {
|
||||
for (k, out_k) in output.iter_mut().enumerate() {
|
||||
let ck = if k == 0 {
|
||||
(1.0 / 8.0_f64).sqrt()
|
||||
} else {
|
||||
(2.0 / 8.0_f64).sqrt()
|
||||
};
|
||||
let mut sum = 0.0;
|
||||
for i in 0..8 {
|
||||
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
for (i, &x) in input.iter().enumerate() {
|
||||
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
}
|
||||
output[k] = ck * sum;
|
||||
*out_k = ck * sum;
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for i in 0..8 {
|
||||
for (i, out_i) in output.iter_mut().enumerate() {
|
||||
let mut sum = 0.0;
|
||||
for k in 0..8 {
|
||||
for (k, &x) in input.iter().enumerate() {
|
||||
let ck = if k == 0 {
|
||||
(1.0 / 8.0_f64).sqrt()
|
||||
} else {
|
||||
(2.0 / 8.0_f64).sqrt()
|
||||
};
|
||||
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
}
|
||||
output[i] = sum;
|
||||
*out_i = sum;
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
///
|
||||
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
||||
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
|
||||
for chunk in bits.chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (i, &bit) in chunk.iter().enumerate() {
|
||||
|
||||
220
crates/relicario-core/src/import_lastpass.rs
Normal file
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)
|
||||
}
|
||||
@@ -8,6 +8,10 @@ use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Steam Mobile Authenticator's 5-character output alphabet.
|
||||
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
||||
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TotpCore {
|
||||
pub config: TotpConfig,
|
||||
@@ -48,26 +52,23 @@ pub enum TotpAlgorithm {
|
||||
Sha512,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TotpKind {
|
||||
#[default]
|
||||
Totp,
|
||||
Hotp { counter: u64 },
|
||||
Steam,
|
||||
}
|
||||
|
||||
impl Default for TotpKind {
|
||||
fn default() -> Self { TotpKind::Totp }
|
||||
}
|
||||
|
||||
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
|
||||
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
||||
///
|
||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||
/// For HOTP: uses the `counter` carried in the variant.
|
||||
/// HOTP is not supported — returns [`RelicarioError::HotpNotSupported`].
|
||||
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
||||
let counter = match config.kind {
|
||||
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
||||
TotpKind::Hotp { counter } => counter,
|
||||
TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported),
|
||||
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
||||
};
|
||||
let counter_bytes = counter.to_be_bytes();
|
||||
@@ -96,6 +97,16 @@ pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<S
|
||||
| ((hmac_out[offset + 1] as u32) << 16)
|
||||
| ((hmac_out[offset + 2] as u32) << 8)
|
||||
| (hmac_out[offset + 3] as u32);
|
||||
if matches!(config.kind, TotpKind::Steam) {
|
||||
let mut t = truncated;
|
||||
let mut out = String::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
|
||||
t /= 26;
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let modulus = 10u32.pow(config.digits as u32);
|
||||
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||
}
|
||||
@@ -151,7 +162,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotp_carries_counter() {
|
||||
fn hotp_kind_roundtrips_through_json() {
|
||||
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -159,6 +170,18 @@ mod tests {
|
||||
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
||||
other => panic!("expected Hotp, got {:?}", other),
|
||||
}
|
||||
// Note: compute_totp_code will reject this — HOTP not supported
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotp_returns_not_supported_error() {
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
|
||||
kind: TotpKind::Hotp { counter: 0 },
|
||||
..TotpConfig::default()
|
||||
};
|
||||
let result = compute_totp_code(&cfg, 0);
|
||||
assert!(matches!(result, Err(RelicarioError::HotpNotSupported)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -168,3 +191,103 @@ mod tests {
|
||||
assert!(json.contains("steam"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod steam_tests {
|
||||
use super::*;
|
||||
|
||||
/// Reference implementation of the Steam 5-character output, per the
|
||||
/// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
|
||||
/// Used by tests below to cross-check the production impl without
|
||||
/// requiring a third-party vector. The algorithm is short enough to
|
||||
/// be reproduced here in isolation.
|
||||
fn steam_output_reference(truncated: u32) -> String {
|
||||
const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||
let mut t = truncated;
|
||||
let mut out = String::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
out.push(ALPHA[(t % 26) as usize] as char);
|
||||
t /= 26;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the dynamic-truncated u32 the same way `compute_totp_code`
|
||||
/// does internally — used to drive the reference impl.
|
||||
fn truncated_for(secret: &[u8], counter: u64) -> u32 {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
|
||||
mac.update(&counter.to_be_bytes());
|
||||
let bytes = mac.finalize().into_bytes();
|
||||
let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
|
||||
((bytes[offset] as u32 & 0x7F) << 24)
|
||||
| ((bytes[offset + 1] as u32) << 16)
|
||||
| ((bytes[offset + 2] as u32) << 8)
|
||||
| (bytes[offset + 3] as u32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_matches_reference_impl() {
|
||||
let secret = b"12345678901234567890".to_vec();
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret.clone()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 5,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
let code_at_30 = compute_totp_code(&cfg, 30).unwrap();
|
||||
let code_at_60 = compute_totp_code(&cfg, 60).unwrap();
|
||||
let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
|
||||
assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1)));
|
||||
assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2)));
|
||||
assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_is_exactly_5_chars_regardless_of_digits() {
|
||||
let secret = b"hello world!".to_vec();
|
||||
for digits in [4u8, 5, 6, 7, 8] {
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret.clone()),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
let code = compute_totp_code(&cfg, 0).unwrap();
|
||||
assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_output_uses_only_alphabet_chars() {
|
||||
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
let secret = b"hello world!".to_vec();
|
||||
let cfg = TotpConfig {
|
||||
secret: Zeroizing::new(secret),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 5,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Steam,
|
||||
};
|
||||
for t in 0u64..1000 {
|
||||
let code = compute_totp_code(&cfg, t * 30).unwrap();
|
||||
for ch in code.chars() {
|
||||
assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_alphabet_excludes_ambiguous_glyphs() {
|
||||
// Authoritative Steam Guard alphabet from Valve's Steam Mobile
|
||||
// Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z.
|
||||
// (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.)
|
||||
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] {
|
||||
assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! # 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
|
||||
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
|
||||
@@ -77,3 +77,23 @@ pub use vault::{
|
||||
};
|
||||
|
||||
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};
|
||||
|
||||
pub mod device;
|
||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||
|
||||
pub mod tar_safe;
|
||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||
|
||||
pub mod recovery_qr;
|
||||
pub use recovery_qr::{
|
||||
generate_recovery_qr, generate_recovery_qr_with_params,
|
||||
recovery_qr_to_svg,
|
||||
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
|
||||
RecoveryQrPayload,
|
||||
};
|
||||
|
||||
284
crates/relicario-core/src/recovery_qr.rs
Normal file
284
crates/relicario-core/src/recovery_qr.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! Recovery-QR encoding for the reference image_secret.
|
||||
//!
|
||||
//! ## What this module produces
|
||||
//!
|
||||
//! Given a user-chosen recovery passphrase and the 32-byte image_secret
|
||||
//! (extracted from the reference JPEG via [`crate::imgsecret::extract`]), this
|
||||
//! module produces a 109-byte sealed payload that — at recovery time, with the
|
||||
//! same passphrase — yields the original image_secret back. The payload is
|
||||
//! intended to be rendered as a QR v40 EcLevel::M SVG via [`recovery_qr_to_svg`]
|
||||
//! and printed on paper, so a user who loses access to the reference JPEG can
|
||||
//! still unlock their vault if they remember the recovery passphrase.
|
||||
//!
|
||||
//! ## Why the format is structured this way
|
||||
//!
|
||||
//! The payload is an XChaCha20-Poly1305 envelope around the image_secret. The
|
||||
//! AEAD key (the "wrap key") is derived by Argon2id from a domain-separated
|
||||
//! input:
|
||||
//!
|
||||
//! ```text
|
||||
//! kdf_input = b"relicario-recovery-v1\0"
|
||||
//! || u64_be(len(nfc(passphrase)))
|
||||
//! || nfc(passphrase)
|
||||
//! wrap_key = Argon2id(kdf_input, kdf_salt, RECOVERY_PRODUCTION_PARAMS) -> 32 bytes
|
||||
//! ```
|
||||
//!
|
||||
//! The `b"relicario-recovery-v1\0"` prefix is **domain separation**: it
|
||||
//! guarantees that even if the user reuses their vault passphrase as their
|
||||
//! recovery passphrase, the wrap key derived here can never collide with a
|
||||
//! vault master key derived in [`crate::crypto::derive_master_key`] (which has
|
||||
//! a different input shape entirely — passphrase + image_secret, no prefix).
|
||||
//! Without this prefix, a determined attacker who somehow recovered a wrap key
|
||||
//! could try it as a master key and vice versa.
|
||||
//!
|
||||
//! Both `kdf_salt` and `wrap_nonce` are freshly randomized per call to
|
||||
//! [`generate_recovery_qr`], so two QRs printed from the same passphrase and
|
||||
//! image_secret are different bytes — the printed QR does not leak whether
|
||||
//! the user has printed others before.
|
||||
//!
|
||||
//! ## Parameter-pinning rationale
|
||||
//!
|
||||
//! The Argon2id parameters used here are NOT [`crate::crypto::KdfParams::default`].
|
||||
//! They are pinned in `RECOVERY_PRODUCTION_PARAMS` at the value
|
||||
//! `KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }` — the same values
|
||||
//! the default happens to have *today*, but deliberately re-stated rather than
|
||||
//! referenced. This is because `KdfParams::default()` may evolve as we re-tune
|
||||
//! Argon2 cost for newer hardware, and a recovery QR printed on paper has no
|
||||
//! way to negotiate parameters at decode time. Changing the pinned values here
|
||||
//! would silently invalidate every recovery QR a user has ever printed under
|
||||
//! the previous parameter set. The const lives at module scope so the
|
||||
//! "pinned, do not change once shipped" property is visible at every use site.
|
||||
|
||||
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
|
||||
use rand::RngCore;
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use zeroize::Zeroizing;
|
||||
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
|
||||
|
||||
// Recovery QR payload — 109 bytes total:
|
||||
//
|
||||
// byte field length
|
||||
// ------ -------------- ------
|
||||
// 0..4 MAGIC = "RREC" 4
|
||||
// 4..5 VERSION = 0x01 1
|
||||
// 5..37 kdf_salt 32 (random per QR)
|
||||
// 37..61 wrap_nonce 24 (random per QR)
|
||||
// 61..109 ciphertext 48 (32 image_secret + 16 AEAD tag)
|
||||
// ------------------------------
|
||||
// total 109
|
||||
const MAGIC: &[u8; 4] = b"RREC";
|
||||
const VERSION: u8 = 0x01;
|
||||
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
|
||||
|
||||
// Static assertion that the documented layout above and the PAYLOAD_LEN
|
||||
// constant cannot drift apart. If a future edit changes one without the other,
|
||||
// this fails to compile.
|
||||
const _: () = assert!(PAYLOAD_LEN == 4 + 1 + 32 + 24 + 48);
|
||||
|
||||
// Named slice ranges derived from the layout offsets above. Used by
|
||||
// `unwrap_recovery_qr_with_params` so the byte-position arithmetic at the
|
||||
// parse site is self-documenting.
|
||||
const KDF_SALT_RANGE: std::ops::Range<usize> = 5..37;
|
||||
const WRAP_NONCE_RANGE: std::ops::Range<usize> = 37..61;
|
||||
const CIPHERTEXT_RANGE: std::ops::Range<usize> = 61..109;
|
||||
|
||||
/// Pinned recovery-QR Argon2id parameters. Re-states `KdfParams::default()`'s
|
||||
/// values rather than referencing them, because a recovery QR printed under
|
||||
/// one parameter set cannot be decoded under another. **Once shipped, these
|
||||
/// values MUST NOT change** — doing so silently invalidates every previously
|
||||
/// printed QR. See the module header for full rationale.
|
||||
const RECOVERY_PRODUCTION_PARAMS: KdfParams = KdfParams {
|
||||
argon2_m: 65536,
|
||||
argon2_t: 3,
|
||||
argon2_p: 4,
|
||||
};
|
||||
|
||||
/// A sealed 109-byte recovery payload. The bytes are an opaque package — they
|
||||
/// only become useful when fed back through [`unwrap_recovery_qr`] together
|
||||
/// with the recovery passphrase that was used to produce them.
|
||||
///
|
||||
/// [`as_bytes`](Self::as_bytes) is the only accessor. The bytes are designed to
|
||||
/// travel as a single unit; the supported transport is rendering via
|
||||
/// [`recovery_qr_to_svg`] and printing the QR on paper, but a hex string
|
||||
/// (sneakernet-friendly) works equally well as long as the full 109 bytes
|
||||
/// are preserved.
|
||||
pub struct RecoveryQrPayload {
|
||||
bytes: [u8; PAYLOAD_LEN],
|
||||
}
|
||||
|
||||
impl RecoveryQrPayload {
|
||||
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
|
||||
let nfc: String = passphrase.nfc().collect();
|
||||
let nfc_bytes = nfc.as_bytes();
|
||||
let prefix = b"relicario-recovery-v1\0";
|
||||
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
|
||||
input.extend_from_slice(prefix);
|
||||
// length-prefix on nfc_bytes mirrors crypto::derive_master_key (audit H1)
|
||||
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
|
||||
input.extend_from_slice(nfc_bytes);
|
||||
input
|
||||
}
|
||||
|
||||
fn derive_wrap_key(
|
||||
passphrase: &str,
|
||||
kdf_salt: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let input = recovery_kdf_input(passphrase);
|
||||
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
|
||||
}
|
||||
|
||||
/// Produce a sealed [`RecoveryQrPayload`] from the recovery passphrase and the
|
||||
/// 32-byte image_secret.
|
||||
///
|
||||
/// # Inputs
|
||||
///
|
||||
/// - `passphrase`: the user's recovery passphrase (UTF-8). Independent of the
|
||||
/// vault passphrase, but the user may reuse them — the
|
||||
/// `b"relicario-recovery-v1\0"` domain-separation prefix in the KDF input
|
||||
/// guarantees the wrap key still cannot collide with a vault master key.
|
||||
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG
|
||||
/// via [`crate::imgsecret::extract`].
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// A [`RecoveryQrPayload`] whose 109 bytes encode `MAGIC || VERSION || kdf_salt
|
||||
/// || wrap_nonce || ciphertext`. Both `kdf_salt` and `wrap_nonce` are freshly
|
||||
/// drawn from `OsRng` on every call, so two payloads generated from the same
|
||||
/// `(passphrase, image_secret)` pair are distinct bit-for-bit. The printed QR
|
||||
/// therefore does not reveal that the user has printed others before.
|
||||
///
|
||||
/// To render the payload as a printable SVG, see [`recovery_qr_to_svg`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RelicarioError::RecoveryQr`] if the AEAD wrap fails (extremely
|
||||
/// unlikely in practice — this can only happen if the cipher implementation
|
||||
/// itself errors, not on user input).
|
||||
pub fn generate_recovery_qr(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8; 32],
|
||||
) -> Result<RecoveryQrPayload> {
|
||||
generate_recovery_qr_with_params(passphrase, image_secret, &RECOVERY_PRODUCTION_PARAMS)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn generate_recovery_qr_with_params(
|
||||
passphrase: &str,
|
||||
image_secret: &[u8; 32],
|
||||
params: &KdfParams,
|
||||
) -> Result<RecoveryQrPayload> {
|
||||
let mut kdf_salt = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
|
||||
|
||||
let mut wrap_nonce = [0u8; 24];
|
||||
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
|
||||
|
||||
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
||||
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
|
||||
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
|
||||
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
|
||||
|
||||
let mut bytes = [0u8; PAYLOAD_LEN];
|
||||
let mut pos = 0;
|
||||
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
|
||||
bytes[pos] = VERSION; pos += 1;
|
||||
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
|
||||
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
|
||||
bytes[pos..pos+48].copy_from_slice(&ciphertext);
|
||||
|
||||
Ok(RecoveryQrPayload { bytes })
|
||||
}
|
||||
|
||||
/// Decode a recovery payload back into the original 32-byte image_secret.
|
||||
///
|
||||
/// # Inputs
|
||||
///
|
||||
/// - `payload_bytes`: the 109 bytes produced by [`generate_recovery_qr`] (after
|
||||
/// the QR has been scanned, or the hex transcribed and decoded).
|
||||
/// - `passphrase`: the recovery passphrase that was used at generate time.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// The recovered image_secret as `Zeroizing<[u8; 32]>` — the wrapper ensures
|
||||
/// the secret is wiped from memory when the binding goes out of scope, so a
|
||||
/// caller that immediately feeds it into [`crate::crypto::derive_master_key`]
|
||||
/// and then drops it never leaves a copy in process memory longer than
|
||||
/// strictly necessary.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - [`RelicarioError::RecoveryQr`] for **format** problems: wrong length,
|
||||
/// bad magic, unsupported version byte. These come from inspecting the
|
||||
/// bytes themselves, before any cryptographic work, so they leak nothing
|
||||
/// about whether the passphrase is right.
|
||||
/// - [`RelicarioError::Decrypt`] for **AEAD** failure — wrong passphrase
|
||||
/// (wrong wrap key) **or** a payload tampered after the fact. The two
|
||||
/// cases are deliberately not distinguished, mirroring the same
|
||||
/// non-distinguishing rejection as [`crate::crypto::decrypt`] (audit M4):
|
||||
/// a Poly1305 tag failure cannot, in principle, leak which bytes were
|
||||
/// wrong, and the API surface preserves that property.
|
||||
pub fn unwrap_recovery_qr(
|
||||
payload_bytes: &[u8],
|
||||
passphrase: &str,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &RECOVERY_PRODUCTION_PARAMS)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn unwrap_recovery_qr_with_params(
|
||||
payload_bytes: &[u8],
|
||||
passphrase: &str,
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
if payload_bytes.len() != PAYLOAD_LEN {
|
||||
return Err(RelicarioError::RecoveryQr(
|
||||
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
|
||||
));
|
||||
}
|
||||
if &payload_bytes[0..4] != MAGIC {
|
||||
return Err(RelicarioError::RecoveryQr("bad magic".into()));
|
||||
}
|
||||
if payload_bytes[4] != VERSION {
|
||||
return Err(RelicarioError::RecoveryQr(
|
||||
format!("unsupported version 0x{:02x}", payload_bytes[4])
|
||||
));
|
||||
}
|
||||
let kdf_salt: &[u8; 32] = payload_bytes[KDF_SALT_RANGE].try_into().expect("slice length validated above");
|
||||
let wrap_nonce = &payload_bytes[WRAP_NONCE_RANGE];
|
||||
let ciphertext = &payload_bytes[CIPHERTEXT_RANGE];
|
||||
|
||||
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
||||
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| RelicarioError::Decrypt)?;
|
||||
|
||||
let mut out = Zeroizing::new([0u8; 32]);
|
||||
out.copy_from_slice(&plaintext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render a [`RecoveryQrPayload`] as a printable QR-code SVG string.
|
||||
///
|
||||
/// The QR is encoded at **version 40** (the largest standard symbol, 177×177
|
||||
/// modules) at **error-correction level M** (~15% recoverable), with a
|
||||
/// minimum rendered dimension of **140×140** SVG units. The 109-byte payload
|
||||
/// fits comfortably inside v40 at level M — there is significant
|
||||
/// error-correction headroom left over, which is the point: the QR is
|
||||
/// expected to live on paper (where smudges, folds, and fading are normal)
|
||||
/// and must still scan years later.
|
||||
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
|
||||
use qrcode::{QrCode, EcLevel};
|
||||
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
|
||||
.expect("109 bytes fits well within QR v40 capacity at EcLevel::M");
|
||||
code.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(140, 140)
|
||||
.build()
|
||||
}
|
||||
138
crates/relicario-core/src/tar_safe.rs
Normal file
138
crates/relicario-core/src/tar_safe.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Safe tar unpacking for backup restore.
|
||||
//!
|
||||
//! The standard `tar::Archive::unpack` has no guards against path traversal,
|
||||
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
|
||||
//! with `safe_unpack_git_archive`, which validates every entry before returning
|
||||
//! `(relative_path, bytes)` pairs to the caller.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
use tar::EntryType;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
|
||||
/// regular files only.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
|
||||
///
|
||||
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
|
||||
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
|
||||
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
|
||||
/// - An entry is a symlink or hardlink — "symlink/link rejected".
|
||||
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
|
||||
pub fn safe_unpack_git_archive(
|
||||
tar_bytes: &[u8],
|
||||
max_uncompressed_bytes: u64,
|
||||
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
|
||||
let mut archive = tar::Archive::new(tar_bytes);
|
||||
let entries = archive
|
||||
.entries()
|
||||
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
|
||||
|
||||
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
|
||||
let mut cumulative: u64 = 0;
|
||||
|
||||
for entry in entries {
|
||||
let mut entry = entry.map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
|
||||
})?;
|
||||
|
||||
let header = entry.header();
|
||||
let entry_type = header.entry_type();
|
||||
|
||||
// Reject symlinks and hardlinks.
|
||||
match entry_type {
|
||||
EntryType::Symlink => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"symlink entry rejected".to_string(),
|
||||
));
|
||||
}
|
||||
EntryType::Link => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"hardlink entry rejected".to_string(),
|
||||
));
|
||||
}
|
||||
EntryType::Directory => {
|
||||
// Directories are implicit — skip without reading body.
|
||||
continue;
|
||||
}
|
||||
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
|
||||
// These are normal file types; fall through to path checks.
|
||||
}
|
||||
_ => {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"unexpected entry type: {:?}",
|
||||
entry_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the path.
|
||||
let path = entry.path().map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
|
||||
})?;
|
||||
let path = path.into_owned();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry contains '..' component".to_string(),
|
||||
));
|
||||
}
|
||||
Component::RootDir => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry has absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
Component::Prefix(_) => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry has Windows drive prefix".to_string(),
|
||||
));
|
||||
}
|
||||
Component::Normal(_) | Component::CurDir => {
|
||||
// Acceptable components.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check declared size before reading body.
|
||||
let claimed = header.size().map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
|
||||
})?;
|
||||
|
||||
if claimed > max_uncompressed_bytes {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
|
||||
)));
|
||||
}
|
||||
|
||||
let new_total = cumulative.saturating_add(claimed);
|
||||
if new_total > max_uncompressed_bytes {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the file body.
|
||||
let mut body = Vec::with_capacity(claimed as usize);
|
||||
entry.read_to_end(&mut body).map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
|
||||
})?;
|
||||
|
||||
cumulative += body.len() as u64;
|
||||
|
||||
result.push((path, body));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ impl MonthYear {
|
||||
if !(1..=12).contains(&month) {
|
||||
return Err("month must be 1..=12");
|
||||
}
|
||||
if year < 2000 || year > 2099 {
|
||||
if !(2000..=2099).contains(&year) {
|
||||
return Err("year must be 2000..=2099");
|
||||
}
|
||||
Ok(Self { month, year })
|
||||
|
||||
215
crates/relicario-core/tests/backup.rs
Normal file
215
crates/relicario-core/tests/backup.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
//! 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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_roundtrip_with_nfd_passphrase() {
|
||||
// "café" in NFD (decomposed: e + combining acute accent)
|
||||
let nfd_passphrase = "caf\u{0065}\u{0301}";
|
||||
// "café" in NFC (precomposed é)
|
||||
let nfc_passphrase = "caf\u{00E9}";
|
||||
|
||||
let input = 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: &[1, 2, 3],
|
||||
settings_enc: &[4, 5, 6],
|
||||
items: vec![],
|
||||
attachments: vec![],
|
||||
reference_jpg: None,
|
||||
git_archive: None,
|
||||
};
|
||||
|
||||
// Pack with NFD passphrase
|
||||
let packed = pack_backup(input, nfd_passphrase).unwrap();
|
||||
|
||||
// Unpack with NFC passphrase — should work after fix
|
||||
let unpacked = unpack_backup(&packed, nfc_passphrase).unwrap();
|
||||
assert_eq!(unpacked.manifest_enc, vec![1, 2, 3]);
|
||||
}
|
||||
276
crates/relicario-core/tests/import_lastpass.rs
Normal file
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}");
|
||||
}
|
||||
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use relicario_core::{
|
||||
crypto::KdfParams,
|
||||
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
|
||||
};
|
||||
|
||||
fn fast_params() -> KdfParams {
|
||||
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
|
||||
}
|
||||
|
||||
fn test_secret() -> [u8; 32] {
|
||||
let mut s = [0u8; 32];
|
||||
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_recovers_image_secret() {
|
||||
let passphrase = "correct-horse-battery-staple";
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
|
||||
.expect("unwrap ok");
|
||||
assert_eq!(recovered.as_ref(), &secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails_decrypt() {
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_is_109_bytes() {
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
assert_eq!(payload.as_bytes().len(), 109);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_output_is_non_empty_xml() {
|
||||
let secret = test_secret();
|
||||
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
|
||||
.expect("generate ok");
|
||||
let svg = recovery_qr_to_svg(&payload);
|
||||
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
|
||||
assert!(!svg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_magic_returns_error() {
|
||||
let mut bad = [0u8; 109];
|
||||
bad[0..4].copy_from_slice(b"NOPE");
|
||||
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use std::path::PathBuf;
|
||||
use tar::{Builder, Header, EntryType};
|
||||
use relicario_core::safe_unpack_git_archive;
|
||||
|
||||
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
|
||||
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
|
||||
/// manually to produce truly malicious archives.
|
||||
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 512]; // one header block
|
||||
|
||||
// Bytes 0-99: name field (null-padded)
|
||||
let name_len = raw_path.len().min(100);
|
||||
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
|
||||
|
||||
// Bytes 100-107: mode = "0000644\0"
|
||||
buf[100..108].copy_from_slice(b"0000644\0");
|
||||
|
||||
// Bytes 108-115: uid
|
||||
buf[108..116].copy_from_slice(b"0000000\0");
|
||||
|
||||
// Bytes 116-123: gid
|
||||
buf[116..124].copy_from_slice(b"0000000\0");
|
||||
|
||||
// Bytes 124-135: size (octal, 11 digits + null)
|
||||
let size_str = format!("{:011o}\0", content.len());
|
||||
buf[124..136].copy_from_slice(size_str.as_bytes());
|
||||
|
||||
// Bytes 136-147: mtime
|
||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||
|
||||
// Bytes 148-155: checksum placeholder (spaces during compute)
|
||||
buf[148..156].copy_from_slice(b" ");
|
||||
|
||||
// Byte 156: typeflag = '0' (regular file)
|
||||
buf[156] = b'0';
|
||||
|
||||
// Bytes 257-262: magic "ustar\0"
|
||||
buf[257..263].copy_from_slice(b"ustar\0");
|
||||
// Bytes 263-264: version "00"
|
||||
buf[263..265].copy_from_slice(b"00");
|
||||
|
||||
// Compute checksum (sum of all bytes, checksum field treated as spaces).
|
||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||
|
||||
// Append padded content blocks.
|
||||
let mut out = buf;
|
||||
if !content.is_empty() {
|
||||
out.extend_from_slice(content);
|
||||
// Pad to 512-byte boundary.
|
||||
let remainder = content.len() % 512;
|
||||
if remainder != 0 {
|
||||
out.extend(vec![0u8; 512 - remainder]);
|
||||
}
|
||||
}
|
||||
|
||||
// Two zero blocks = end-of-archive.
|
||||
out.extend(vec![0u8; 1024]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a tar with a raw symlink entry (typeflag = '2').
|
||||
fn raw_symlink_tar() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 512];
|
||||
|
||||
// name
|
||||
buf[..9].copy_from_slice(b"evil_link");
|
||||
// mode
|
||||
buf[100..108].copy_from_slice(b"0000755\0");
|
||||
// uid/gid
|
||||
buf[108..116].copy_from_slice(b"0000000\0");
|
||||
buf[116..124].copy_from_slice(b"0000000\0");
|
||||
// size = 0
|
||||
buf[124..136].copy_from_slice(b"00000000000\0");
|
||||
// mtime
|
||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||
// checksum placeholder
|
||||
buf[148..156].copy_from_slice(b" ");
|
||||
// typeflag = '2' (symlink)
|
||||
buf[156] = b'2';
|
||||
// linkname
|
||||
let target = b"/etc/passwd";
|
||||
buf[157..157 + target.len()].copy_from_slice(target);
|
||||
// magic
|
||||
buf[257..263].copy_from_slice(b"ustar\0");
|
||||
buf[263..265].copy_from_slice(b"00");
|
||||
|
||||
// Compute checksum.
|
||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||
|
||||
let mut out = buf;
|
||||
out.extend(vec![0u8; 1024]); // end-of-archive
|
||||
out
|
||||
}
|
||||
|
||||
fn build_normal_tar() -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut buf);
|
||||
let content = b"hello";
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
|
||||
.unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn build_oversize_tar() -> Vec<u8> {
|
||||
// Actual 2048-byte body; test will use cap=1024
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut buf);
|
||||
let content = vec![0u8; 2048];
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "bigfile.bin", content.as_slice())
|
||||
.unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_path_traversal() {
|
||||
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
|
||||
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("path traversal") || msg.contains(".."),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_absolute_path() {
|
||||
// Craft a tar with "/etc/escaped.txt" using raw bytes.
|
||||
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("path traversal") || msg.contains("absolute"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_symlink() {
|
||||
let bytes = raw_symlink_tar();
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("symlink") || msg.contains("link"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_size_bomb() {
|
||||
let bytes = build_oversize_tar(); // actual 2048-byte entry
|
||||
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_accepts_normal_files() {
|
||||
let buf = build_normal_tar();
|
||||
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
|
||||
assert_eq!(entries[0].1, b"hello");
|
||||
}
|
||||
18
crates/relicario-server/Cargo.toml
Normal file
18
crates/relicario-server/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "relicario-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
189
crates/relicario-server/src/main.rs
Normal file
189
crates/relicario-server/src/main.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! relicario-server -- pre-receive hook for signature verification.
|
||||
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "relicario-server")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Verify a commit's signature against devices.json.
|
||||
VerifyCommit {
|
||||
/// The commit SHA to verify.
|
||||
commit: String,
|
||||
},
|
||||
/// Generate a pre-receive hook script.
|
||||
GenerateHook,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
||||
Commands::GenerateHook => generate_hook(),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_commit(commit: &str) -> Result<()> {
|
||||
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
||||
Ok(json) => json,
|
||||
Err(_) => {
|
||||
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
||||
.context("parse devices.json")?;
|
||||
|
||||
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// True bootstrap: no devices ever registered and none revoked.
|
||||
if devices.is_empty() && revoked.is_empty() {
|
||||
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build temp allowed-signers file from registered devices.
|
||||
let tmp = tempfile::tempdir().context("create tempdir")?;
|
||||
let allowed_path = tmp.path().join("allowed_signers");
|
||||
let mut allowed_body = String::new();
|
||||
for d in &devices {
|
||||
allowed_body.push_str("relicario ");
|
||||
allowed_body.push_str(d.public_key.trim());
|
||||
allowed_body.push('\n');
|
||||
}
|
||||
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
|
||||
|
||||
// Run git verify-commit --raw. Capture both exit code and stderr.
|
||||
// NOTE: we do NOT short-circuit on non-zero exit here because even for
|
||||
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
|
||||
let output = Command::new("git")
|
||||
.args(["verify-commit", "--raw", commit])
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||
.output()
|
||||
.context("git verify-commit")?;
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Parse the SHA-256 fingerprint from stderr.
|
||||
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
|
||||
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||
Some(m) => m.as_str().to_string(),
|
||||
None => {
|
||||
// No fingerprint in stderr = unsigned or completely malformed signature.
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — no valid signature found (stderr: {})",
|
||||
stderr.trim()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Build fingerprint → entry maps.
|
||||
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
|
||||
std::collections::HashMap::new();
|
||||
for d in &devices {
|
||||
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
|
||||
device_by_fp.insert(fp, d);
|
||||
}
|
||||
}
|
||||
|
||||
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
|
||||
std::collections::HashMap::new();
|
||||
for r in &revoked {
|
||||
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
|
||||
revoked_by_fp.insert(fp, r);
|
||||
}
|
||||
}
|
||||
|
||||
// Get committer date (NOT author date).
|
||||
let ct_out = Command::new("git")
|
||||
.args(["show", "-s", "--format=%ct", commit])
|
||||
.output()
|
||||
.context("git show committer date")?;
|
||||
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.context("parse committer timestamp")?;
|
||||
|
||||
// Check revocation FIRST (revoked entries may not be in devices anymore).
|
||||
if let Some(r) = revoked_by_fp.get(&signing_fp) {
|
||||
if committer_ts >= r.revoked_at {
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — signed by revoked device '{}' \
|
||||
(committer ts {committer_ts} >= revoked_at {})",
|
||||
r.name, r.revoked_at
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Historical commit: committer_ts < revoked_at → was valid when signed.
|
||||
eprintln!(
|
||||
"OK: commit {commit} — historical commit signed by '{}' before revocation",
|
||||
r.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Not revoked — must be in active devices.
|
||||
if !device_by_fp.contains_key(&signing_fp) {
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_hook() -> Result<()> {
|
||||
print!(
|
||||
r#"#!/bin/bash
|
||||
# Relicario pre-receive hook -- verify all commits are signed by registered devices
|
||||
|
||||
while read oldrev newrev refname; do
|
||||
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
|
||||
|
||||
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
|
||||
commits=$(git rev-list "$newrev")
|
||||
else
|
||||
commits=$(git rev-list "$oldrev..$newrev")
|
||||
fi
|
||||
|
||||
for commit in $commits; do
|
||||
relicario-server verify-commit "$commit" || exit 1
|
||||
done
|
||||
done
|
||||
"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn git_show(commit: &str, path: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["show", &format!("{}:{}", commit, path)])
|
||||
.output()
|
||||
.context("git show")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("git show {}:{} failed", commit, path);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
230
crates/relicario-server/tests/verify_commit.rs
Normal file
230
crates/relicario-server/tests/verify_commit.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
//! Acceptance tests for `relicario-server verify-commit`.
|
||||
//!
|
||||
//! Four scenarios from audit S1:
|
||||
//! 1. Registered non-revoked key → exit 0
|
||||
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
|
||||
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
|
||||
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use predicates::prelude::*;
|
||||
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
|
||||
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||
let priv_path = dir.join(format!("{name}.key"));
|
||||
let pub_path = dir.join(format!("{name}.pub"));
|
||||
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||
fs::write(&pub_path, &pub_line).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
(priv_path, pub_path, pub_line)
|
||||
}
|
||||
|
||||
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(repo).args(args);
|
||||
for (k, v) in extra_env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let status = cmd.status().expect("spawn git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn init_repo(repo: &Path) {
|
||||
git(repo, &["init", "-q", "-b", "main"], &[]);
|
||||
git(repo, &["config", "user.email", "test@test"], &[]);
|
||||
git(repo, &["config", "user.name", "test"], &[]);
|
||||
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
|
||||
}
|
||||
|
||||
fn sign_commit(
|
||||
repo: &Path,
|
||||
signing_key: &Path,
|
||||
allowed_signers: &Path,
|
||||
committer_unix: i64,
|
||||
msg: &str,
|
||||
file_path: &str,
|
||||
file_content: &str,
|
||||
) -> String {
|
||||
fs::write(repo.join(file_path), file_content).unwrap();
|
||||
git(repo, &["add", file_path], &[]);
|
||||
let date = format!("@{committer_unix} +0000");
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c", "gpg.format=ssh",
|
||||
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
|
||||
"commit", "-S", "-q", "-m", msg,
|
||||
],
|
||||
&[
|
||||
("GIT_AUTHOR_DATE", &date),
|
||||
("GIT_COMMITTER_DATE", &date),
|
||||
],
|
||||
);
|
||||
let out = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
|
||||
let dir = repo.join(".relicario");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
|
||||
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
|
||||
git(repo, &["add", ".relicario"], &[]);
|
||||
git(repo, &["commit", "-q", "-m", "device files"], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_non_revoked_key_accepted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
write_device_files(
|
||||
repo,
|
||||
&[DeviceEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
added_at: 1_700_000_000,
|
||||
added_by: "bootstrap".into(),
|
||||
}],
|
||||
&[],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_key_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (_, _, pub_a) = write_keypair(repo, "alice");
|
||||
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
|
||||
|
||||
// Only Alice is registered.
|
||||
write_device_files(
|
||||
repo,
|
||||
&[DeviceEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
added_at: 1_700_000_000,
|
||||
added_by: "bootstrap".into(),
|
||||
}],
|
||||
&[],
|
||||
);
|
||||
|
||||
// Evil signs against a file containing both keys so git commit signing works,
|
||||
// but the binary's allowed-signers (from devices.json) only has Alice.
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(
|
||||
&allowed,
|
||||
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("unregistered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_key_after_revoked_at_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
|
||||
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
|
||||
write_device_files(
|
||||
repo,
|
||||
&[],
|
||||
&[RevokedEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
revoked_at: 1_705_000_000,
|
||||
revoked_by: "admin".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
// Commit dated AFTER revocation.
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("revoked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_key_before_revoked_at_accepted_historical() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
|
||||
// Same as above: Alice only in revoked.json.
|
||||
write_device_files(
|
||||
repo,
|
||||
&[],
|
||||
&[RevokedEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
revoked_at: 1_705_000_000,
|
||||
revoked_by: "admin".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
// Commit dated BEFORE revocation -- historical case must pass.
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
|
||||
@@ -15,6 +15,11 @@ serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
zeroize = "1"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
once_cell = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
71
crates/relicario-wasm/src/device.rs
Normal file
71
crates/relicario-wasm/src/device.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! WASM device key management -- private keys never cross to JS.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::device as core_device;
|
||||
|
||||
/// In-memory device key storage (private keys held in WASM linear memory).
|
||||
static DEVICE_STATE: Lazy<Mutex<Option<DeviceState>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
struct DeviceState {
|
||||
name: String,
|
||||
signing_private: Zeroizing<String>,
|
||||
signing_public: String,
|
||||
/// Deploy key stored for future SSH git operations; not yet used for signing.
|
||||
#[allow(dead_code)]
|
||||
deploy_private: Zeroizing<String>,
|
||||
deploy_public: String,
|
||||
}
|
||||
|
||||
/// Register a new device, storing the keypairs internally and returning
|
||||
/// only the public keys. Private keys never leave WASM memory.
|
||||
pub fn register_device(name: &str) -> Result<(String, String), String> {
|
||||
let (signing_priv, signing_pub) =
|
||||
core_device::generate_keypair().map_err(|e| e.to_string())?;
|
||||
let (deploy_priv, deploy_pub) =
|
||||
core_device::generate_keypair().map_err(|e| e.to_string())?;
|
||||
|
||||
let state = DeviceState {
|
||||
name: name.to_string(),
|
||||
signing_private: signing_priv,
|
||||
signing_public: signing_pub.clone(),
|
||||
deploy_private: deploy_priv,
|
||||
deploy_public: deploy_pub.clone(),
|
||||
};
|
||||
|
||||
*DEVICE_STATE.lock().unwrap() = Some(state);
|
||||
|
||||
Ok((signing_pub, deploy_pub))
|
||||
}
|
||||
|
||||
/// Sign `data` using the registered device's signing key.
|
||||
/// Returns a base64-encoded signature.
|
||||
pub fn sign_for_git(data: &[u8]) -> Result<String, String> {
|
||||
let guard = DEVICE_STATE.lock().unwrap();
|
||||
let state = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "no device registered".to_string())?;
|
||||
|
||||
core_device::sign(&state.signing_private, data).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Return current device info: (name, signing_public_key, deploy_public_key).
|
||||
/// Returns None if no device has been registered in this session.
|
||||
pub fn get_device_info() -> Option<(String, String, String)> {
|
||||
let guard = DEVICE_STATE.lock().unwrap();
|
||||
guard.as_ref().map(|s| {
|
||||
(
|
||||
s.name.clone(),
|
||||
s.signing_public.clone(),
|
||||
s.deploy_public.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear device state (call on logout or before re-registration).
|
||||
pub fn clear_device() {
|
||||
*DEVICE_STATE.lock().unwrap() = None;
|
||||
}
|
||||
@@ -5,12 +5,20 @@
|
||||
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
||||
|
||||
mod session;
|
||||
mod device;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||
|
||||
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
|
||||
/// Handle returned from `unlock`. Backed by a `u32`; opaque to JS.
|
||||
///
|
||||
/// Dropping the handle (or invoking `.free()` from JS) removes the entry from
|
||||
/// the session registry, zeroizing the wrapped master key and image_secret.
|
||||
/// `lock(handle)` remains available as the explicit early-cleanup path; the
|
||||
/// `Drop` impl is the safety net that catches code paths which forget to call
|
||||
/// `lock` before letting the handle go out of scope.
|
||||
#[wasm_bindgen]
|
||||
pub struct SessionHandle(u32);
|
||||
|
||||
@@ -20,6 +28,23 @@ impl SessionHandle {
|
||||
pub fn value(&self) -> u32 { self.0 }
|
||||
}
|
||||
|
||||
impl Drop for SessionHandle {
|
||||
fn drop(&mut self) { let _ = session::remove(self.0); }
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn __test_make_handle() -> SessionHandle {
|
||||
SessionHandle(session::insert(
|
||||
Zeroizing::new([0x77u8; 32]),
|
||||
Zeroizing::new([0u8; 32]),
|
||||
))
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn __test_session_exists(handle: u32) -> bool {
|
||||
session::with(handle, |_| ()).is_some()
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn unlock(
|
||||
passphrase: &str,
|
||||
@@ -35,7 +60,8 @@ pub fn unlock(
|
||||
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let handle = session::insert(master_key);
|
||||
let stored_secret = Zeroizing::new(image_secret);
|
||||
let handle = session::insert(master_key, stored_secret);
|
||||
Ok(SessionHandle(handle))
|
||||
}
|
||||
|
||||
@@ -120,6 +146,16 @@ pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<V
|
||||
.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 ─────────
|
||||
|
||||
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
|
||||
@@ -196,6 +232,91 @@ pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Register a new device, generating ed25519 keypairs for signing and deploy.
|
||||
/// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." }
|
||||
/// Private keys are kept internal to WASM and never cross to JS.
|
||||
#[wasm_bindgen]
|
||||
pub fn register_device(name: &str) -> Result<JsValue, JsError> {
|
||||
let (signing_pub, deploy_pub) =
|
||||
device::register_device(name).map_err(|e| JsError::new(&e))?;
|
||||
|
||||
js_value_for(&serde_json::json!({
|
||||
"signing_public_key": signing_pub,
|
||||
"deploy_public_key": deploy_pub,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Sign `data` using the registered device's signing key.
|
||||
/// Returns JSON: { "signature": "<base64>" }
|
||||
/// Errors if no device has been registered via register_device().
|
||||
#[wasm_bindgen]
|
||||
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
|
||||
let signature = device::sign_for_git(data).map_err(|e| JsError::new(&e))?;
|
||||
|
||||
js_value_for(&serde_json::json!({
|
||||
"signature": signature,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get the current device's name and public keys.
|
||||
/// Returns JSON: { "name": "...", "signing_public_key": "...", "deploy_public_key": "..." }
|
||||
/// Returns null if no device is registered in this session.
|
||||
#[wasm_bindgen]
|
||||
pub fn get_device_info() -> Result<JsValue, JsError> {
|
||||
match device::get_device_info() {
|
||||
Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({
|
||||
"name": name,
|
||||
"signing_public_key": signing_pub,
|
||||
"deploy_public_key": deploy_pub,
|
||||
})),
|
||||
None => Ok(JsValue::NULL),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the in-memory device state (call on logout or before re-registration).
|
||||
#[wasm_bindgen]
|
||||
pub fn clear_device() {
|
||||
device::clear_device();
|
||||
}
|
||||
|
||||
/// 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]
|
||||
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()))?;
|
||||
@@ -237,6 +358,190 @@ pub fn totp_compute(
|
||||
Ok(TotpCode { code, expires_at })
|
||||
}
|
||||
|
||||
// ── Backup container bridge ─────────────────────────────────────────────────
|
||||
|
||||
use base64::Engine;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// ── Recovery QR bindings ─────────────────────────────────────────────────────
|
||||
|
||||
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
|
||||
|
||||
/// Generate a recovery QR SVG for the current session.
|
||||
/// Returns the SVG string. The passphrase wraps the image_secret under a
|
||||
/// separate key (domain-separated from the master key derivation).
|
||||
#[wasm_bindgen]
|
||||
pub fn wasm_generate_recovery_qr(
|
||||
handle: &SessionHandle,
|
||||
passphrase: &str,
|
||||
) -> Result<String, JsError> {
|
||||
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
|
||||
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(recovery_qr_to_svg(&payload))
|
||||
}
|
||||
|
||||
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
|
||||
/// Returns the raw image_secret bytes (32 bytes).
|
||||
#[wasm_bindgen]
|
||||
pub fn wasm_unwrap_recovery_qr(
|
||||
payload_b64: &str,
|
||||
passphrase: &str,
|
||||
) -> Result<Vec<u8>, JsError> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
let bytes = STANDARD.decode(payload_b64)
|
||||
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
|
||||
let recovered = unwrap_recovery_qr(&bytes, passphrase)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(recovered.to_vec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
@@ -245,16 +550,29 @@ mod session_tests {
|
||||
#[test]
|
||||
fn insert_then_remove_clears_entry() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x11u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
assert_ne!(h, 0);
|
||||
assert!(session::remove(h));
|
||||
assert!(!session::remove(h)); // second remove false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_session_handle_clears_registry_entry() {
|
||||
session::clear();
|
||||
let handle = SessionHandle(session::insert(
|
||||
Zeroizing::new([0x33u8; 32]),
|
||||
Zeroizing::new([0u8; 32]),
|
||||
));
|
||||
let id = handle.value();
|
||||
assert!(session::with(id, |_| ()).is_some());
|
||||
drop(handle);
|
||||
assert!(session::with(id, |_| ()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_yields_key_only_while_session_lives() {
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x22u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
let byte = session::with(h, |k| k[0]);
|
||||
assert_eq!(byte, Some(0x22));
|
||||
session::remove(h);
|
||||
@@ -266,7 +584,7 @@ mod session_tests {
|
||||
fn manifest_round_trip_via_handle() {
|
||||
use relicario_core::{Manifest, decrypt_manifest};
|
||||
session::clear();
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]));
|
||||
let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
let key = Zeroizing::new([0x55u8; 32]);
|
||||
let empty = Manifest::new();
|
||||
@@ -279,4 +597,31 @@ mod session_tests {
|
||||
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub struct SessionData {
|
||||
pub master_key: Zeroizing<[u8; 32]>,
|
||||
pub image_secret: Zeroizing<[u8; 32]>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
|
||||
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
|
||||
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
||||
}
|
||||
|
||||
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
|
||||
let handle = NEXT_HANDLE.with(|n| {
|
||||
let mut n = n.borrow_mut();
|
||||
let h = *n;
|
||||
@@ -19,17 +24,31 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
||||
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||
h
|
||||
});
|
||||
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
||||
SESSIONS.with(|s| {
|
||||
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
|
||||
});
|
||||
handle
|
||||
}
|
||||
|
||||
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
|
||||
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||
{
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
|
||||
}
|
||||
|
||||
/// Access the image_secret for a handle (used by recovery QR).
|
||||
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
|
||||
where
|
||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||
{
|
||||
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
|
||||
}
|
||||
|
||||
/// Remove a session entry. Called by both `lock(handle)` (the explicit
|
||||
/// path) and `impl Drop for SessionHandle` (the safety net). Returns
|
||||
/// `true` if an entry was removed, `false` if the handle was already gone.
|
||||
pub fn remove(handle: u32) -> bool {
|
||||
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
|
||||
}
|
||||
|
||||
16
crates/relicario-wasm/tests/session_drop.rs
Normal file
16
crates/relicario-wasm/tests/session_drop.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! Belt-and-suspenders companion to the native `dropping_session_handle_clears_registry_entry`
|
||||
//! test in `lib.rs`. This file exists for `wasm-pack test --node` symmetry; the
|
||||
//! native test in the same crate is what gates CI.
|
||||
|
||||
use wasm_bindgen_test::wasm_bindgen_test;
|
||||
|
||||
use relicario_wasm::{__test_make_handle, __test_session_exists};
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn dropping_session_handle_clears_registry_entry() {
|
||||
let handle = __test_make_handle();
|
||||
let id = handle.value();
|
||||
assert!(__test_session_exists(id));
|
||||
drop(handle);
|
||||
assert!(!__test_session_exists(id));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# relicario — Architecture
|
||||
# Relicario — Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
@@ -42,15 +42,19 @@
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ GIT SERVER (untrusted) │
|
||||
│ │
|
||||
│ relicario-vault.git/ │
|
||||
│ ├── manifest.enc ← opaque ciphertext │
|
||||
│ ├── entries/ │
|
||||
│ │ ├── a1b2c3d4.enc ← opaque ciphertext │
|
||||
│ │ └── e5f6a7b8.enc ← opaque ciphertext │
|
||||
│ └── .relicario/ │
|
||||
│ relicario-vault.git/ │
|
||||
│ ├── manifest.enc ← opaque ciphertext │
|
||||
│ ├── settings.enc ← opaque ciphertext │
|
||||
│ ├── items/ │
|
||||
│ │ ├── a1b2c3d4e5f6a7b8.enc ← opaque ciphertext │
|
||||
│ │ └── … │
|
||||
│ ├── attachments/ │
|
||||
│ │ └── <item-id>/<aid>.enc ← opaque ciphertext │
|
||||
│ └── .relicario/ │
|
||||
│ ├── salt ← 32 bytes (not secret) │
|
||||
│ ├── params.json ← KDF params (not secret) │
|
||||
│ └── devices.json ← device public keys (not secret) │
|
||||
│ ├── devices.json ← device public keys (not secret) │
|
||||
│ └── revoked.json ← revoked device records (not secret) │
|
||||
│ │
|
||||
│ The server sees NOTHING useful. No keys, no plaintext, │
|
||||
│ no metadata about what's inside. │
|
||||
@@ -79,8 +83,9 @@ vault_salt ────────►│ │
|
||||
|
||||
┌──────────────────┐
|
||||
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
||||
empty manifest ────►│ Poly1305 │
|
||||
└──────────────────┘
|
||||
empty manifest ────►│ Poly1305 │ settings.enc
|
||||
default settings ──►│ encrypt (×2) │ (parallel artifacts;
|
||||
└──────────────────┘ independent nonces)
|
||||
|
||||
┌──────────────────┐
|
||||
│ git init │──────► vault repo
|
||||
@@ -88,6 +93,14 @@ empty manifest ────►│ Poly1305 │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
|
||||
attachment encryption, and field-history tracking are not shown above —
|
||||
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
|
||||
The flow above covers only the crypto-pipeline shape that vault init
|
||||
establishes; the per-item lifecycle reuses the same `master_key` +
|
||||
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
|
||||
`attachments/<item-id>/<aid>.enc`.
|
||||
|
||||
## Unlock Flow (every vault operation)
|
||||
|
||||
```
|
||||
@@ -217,21 +230,23 @@ Input JPEG (possibly re-encoded or cropped)
|
||||
│ uses
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ relicario-core │
|
||||
│ relicario-core │
|
||||
│ Platform-agnostic: bytes in, bytes out │
|
||||
│ No filesystem, no network, no git │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
|
||||
│ │ crypto │ │ imgsecret│ │ entry │ │ vault │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ KDF │ │ DCT │ │ Entry │ │ encrypt_ │ │
|
||||
│ │ encrypt │ │ embed │ │ Manifest│ │ entry() │ │
|
||||
│ │ decrypt │ │ extract │ │ search │ │ decrypt_ │ │
|
||||
│ │ │ │ QIM │ │ │ │ manifest() │ │
|
||||
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
|
||||
│ │ crypto │ │ imgsecret│ │ item + │ │ vault │ │
|
||||
│ │ │ │ │ │ types │ │ │ │
|
||||
│ │ KDF │ │ DCT │ │ Item │ │ encrypt_ │ │
|
||||
│ │ encrypt │ │ embed │ │ Manifest│ │ item() │ │
|
||||
│ │ decrypt │ │ extract │ │ Settings│ │ decrypt_ │ │
|
||||
│ │ │ │ QIM │ │ Backup │ │ manifest() │ │
|
||||
│ │ │ │ │ │ Device │ │ ... │ │
|
||||
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ Future: relicario-wasm wraps this for browser extension │
|
||||
│ Future: JNI/Swift wrappers for Android/iOS │
|
||||
│ Consumed by: relicario-cli, relicario-wasm (extension), │
|
||||
│ relicario-server (pre-receive hook). │
|
||||
│ Future: JNI/Swift wrappers for Android/iOS. │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
104
docs/SECURITY.md
Normal file
104
docs/SECURITY.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Relicario Security Model
|
||||
|
||||
## Cryptographic Protection
|
||||
|
||||
Relicario uses two-factor vault decryption:
|
||||
1. **Passphrase** — user-memorized, zxcvbn score ≥3 required
|
||||
2. **Reference image** — JPEG carrying 256-bit secret via DCT steganography
|
||||
|
||||
Key derivation: Argon2id (64 MiB memory, 3 iterations, 4 parallelism)
|
||||
Encryption: XChaCha20-Poly1305 (192-bit nonce, 256-bit key)
|
||||
|
||||
## Manifest Integrity
|
||||
|
||||
The manifest (`manifest.enc`) is encrypted with AEAD, which provides:
|
||||
|
||||
- **Confidentiality**: Contents unreadable without master key
|
||||
- **Integrity**: Any modification detected and rejected on decrypt
|
||||
- **Authenticity**: Only master key holders can create valid ciphertexts
|
||||
|
||||
### What AEAD Does NOT Protect
|
||||
|
||||
- **Item deletion**: An attacker with write access can delete `.enc` files
|
||||
or git-revert commits. The manifest decrypts successfully but won't
|
||||
contain the deleted items.
|
||||
|
||||
- **Rollback attacks**: An attacker can replace `manifest.enc` with an
|
||||
older valid version. AEAD accepts any ciphertext created with the key.
|
||||
|
||||
### Mitigation
|
||||
|
||||
Item deletion and rollback are detectable via **git history**:
|
||||
|
||||
```bash
|
||||
git log --oneline items/
|
||||
```
|
||||
|
||||
For environments where git history could be rewritten (force-push):
|
||||
|
||||
1. Enable device authentication (commit signing + pre-receive hook)
|
||||
2. Use a git server that rejects non-fast-forward pushes
|
||||
3. Regular backups with `relicario backup export`
|
||||
|
||||
## Device Authentication
|
||||
|
||||
When enabled, device authentication provides:
|
||||
|
||||
- **Commit authorship**: All commits signed by registered device keys
|
||||
- **Push access control**: Deploy keys managed via Gitea API
|
||||
- **Instant revocation**: One command cuts off both signing and push
|
||||
|
||||
Enforcement requires deploying the `relicario-server` pre-receive hook
|
||||
on the vault remote. The crate provides two subcommands:
|
||||
|
||||
- `relicario-server generate-hook` — emits the hook script to install at
|
||||
`<repo>/hooks/pre-receive`
|
||||
- `relicario-server verify-commit <sha>` — checks one commit's signature
|
||||
against `.relicario/devices.json` and `.relicario/revoked.json` as of
|
||||
that commit; the hook calls this for every pushed ref
|
||||
|
||||
Without the server hook, signed commits provide authorship metadata only
|
||||
— any process with push access can land an unsigned commit, since
|
||||
verification is otherwise advisory.
|
||||
|
||||
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
|
||||
|
||||
## Access Control
|
||||
|
||||
Without device authentication, access control is transport-layer only:
|
||||
|
||||
- **CLI**: SSH key authentication to git remote
|
||||
- **Extension**: Git credentials in browser storage
|
||||
|
||||
Device registration is optional but recommended for shared vaults.
|
||||
|
||||
## Configuration env vars
|
||||
|
||||
Relicario reads the following environment variables. Each is a trust
|
||||
boundary: an attacker who can set them in the user's environment can
|
||||
influence Relicario's behavior. They are listed here for security
|
||||
reviewers to audit the surface in one place.
|
||||
|
||||
### User-facing (active in all builds)
|
||||
|
||||
| Variable | Purpose | Trust |
|
||||
|---|---|---|
|
||||
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
||||
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
|
||||
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
|
||||
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
|
||||
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
|
||||
|
||||
### Debug-only (compiled out of `cargo build --release`)
|
||||
|
||||
The following variables are gated behind `cfg(debug_assertions)` and
|
||||
are **no-ops** in release builds. The env-var lookup is removed by the
|
||||
optimiser from any binary built without debug assertions (i.e. the
|
||||
standard `--release` profile).
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
|
||||
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
||||
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
|
||||
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
|
||||
213
docs/architecture/overview.md
Normal file
213
docs/architecture/overview.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Architecture overview — Relicario
|
||||
|
||||
This is the cross-codebase entry point. It describes how the four 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 four codebases
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ relicario-core │
|
||||
│ (Rust, no I/O) │
|
||||
│ crypto · items │
|
||||
│ manifest · stego │
|
||||
│ device keys + fp │
|
||||
└──┬───────────┬──────┘
|
||||
│ │
|
||||
┌────────────────┼───────────┴──────┬────────────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
|
||||
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
|
||||
│ │ │ │ │ bindings) │
|
||||
│ filesystem + │ │ pre-receive hook │ │ │
|
||||
│ git + │ │ verify-commit + │ │ compiled to WASM │
|
||||
│ clap UX │ │ generate-hook │ │ for the extension │
|
||||
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ extension │
|
||||
│ (TypeScript) │
|
||||
│ popup · vault │
|
||||
│ setup · content │
|
||||
│ service worker │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
| Codebase | Language | Role | Key boundary |
|
||||
|---|---|---|---|
|
||||
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. 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. |
|
||||
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
|
||||
| `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. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
|
||||
|
||||
## 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 |
|
||||
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
|
||||
| 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 16-char hex (64 bits) | `core/ids.rs` | Stable, short, no information leak |
|
||||
| Attachment IDs are content-addressed (first 32 hex chars / 128 bits of 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 |
|
||||
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
|
||||
| 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).
|
||||
165
docs/superpowers/MULTI-AGENT.md
Normal file
165
docs/superpowers/MULTI-AGENT.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Multi-Agent Development Paradigm
|
||||
|
||||
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
|
||||
|
||||
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| Role | Terminal | Branch | Responsibilities |
|
||||
|------|----------|--------|-----------------|
|
||||
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
|
||||
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
|
||||
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
|
||||
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
|
||||
|
||||
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
|
||||
|
||||
---
|
||||
|
||||
## Starting a lift
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
|
||||
- [ ] No uncommitted changes in main that would confuse the devs
|
||||
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
|
||||
|
||||
### Launch sequence
|
||||
|
||||
```bash
|
||||
# 1. Start the relay server (this terminal becomes the relay log)
|
||||
tools/relay/start.sh # prints copy-paste instructions, then starts server
|
||||
|
||||
# Optional: use a multiplexer to auto-open all four terminals
|
||||
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
|
||||
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
|
||||
```
|
||||
|
||||
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
|
||||
|
||||
---
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
|
||||
|
||||
| Kind | Block header | When used |
|
||||
|------|-------------|-----------|
|
||||
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
|
||||
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
|
||||
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
|
||||
| `free` | (none) | Ad-hoc messages not covered by the above |
|
||||
|
||||
A well-formed `status` block:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: 2026-05-02T14:30:00-07:00
|
||||
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||
Task: P4 / error-copy map
|
||||
Status: DONE
|
||||
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
|
||||
Tests: green
|
||||
Notes: No issues. Ready for PM review of P4 before starting B1.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using the relay tools
|
||||
|
||||
All three Claude Code sessions have these tools available when the relay server is running:
|
||||
|
||||
```
|
||||
post_message(from, to, kind, body) → { id }
|
||||
read_messages(for) → RelayMessage[] (drains inbox)
|
||||
list_pending(for) → { count, kinds } (non-destructive)
|
||||
```
|
||||
|
||||
Typical dev flow per task:
|
||||
|
||||
```
|
||||
1. read_messages(for="dev-b") # check for directives before starting
|
||||
2. ... do the work ...
|
||||
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
|
||||
```
|
||||
|
||||
Typical PM flow:
|
||||
|
||||
```
|
||||
1. read_messages(for="pm") # see what devs posted
|
||||
2. ... review ...
|
||||
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## If the relay server isn't running
|
||||
|
||||
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
|
||||
|
||||
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
|
||||
|
||||
To restart a crashed server mid-lift:
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh
|
||||
```
|
||||
|
||||
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
|
||||
|
||||
---
|
||||
|
||||
## Generating kickoff prompts
|
||||
|
||||
### Full workflow (spec → plans → kickoff)
|
||||
|
||||
**Step 1 — Write a spec**
|
||||
|
||||
Run the `superpowers:brainstorming` skill. At the end it invokes `superpowers:writing-plans` for each dev stream. Each stream gets its own plan file in `docs/superpowers/plans/`. The spec lives in `docs/superpowers/specs/`.
|
||||
|
||||
**Step 2 — Invoke the kickoff skill**
|
||||
|
||||
Say anything like:
|
||||
- "kick off the multi-agent thing for v0.6.0"
|
||||
- "spin up PM and devs for this release"
|
||||
- "set up the three-terminal paradigm"
|
||||
|
||||
The `multi-agent-kickoff` skill auto-triggers on those phrases. It will:
|
||||
|
||||
1. Auto-discover the spec and plans by date/release label (asks to confirm if ambiguous)
|
||||
2. Generate `docs/superpowers/coordination/<release>-pm-prompt.md` and one `-dev-<letter>-prompt.md` per plan
|
||||
3. Inject the relay paragraph, branch names, worktree paths, test commands, and scope partitioning automatically from the plans and `CLAUDE.md`
|
||||
4. Commit the prompts and print launch instructions
|
||||
|
||||
N>2 devs works automatically — 3 plans produces PM + Dev-A/B/C prompts.
|
||||
|
||||
**Step 3 — Launch**
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh # prints prompt file paths, starts relay server
|
||||
# open N+1 terminals, paste each prompt below its '---' line
|
||||
```
|
||||
|
||||
The skill reminder: run `tools/relay/start.sh` **before** opening the Claude Code sessions — the MCP tools need the server up when each session initializes.
|
||||
|
||||
---
|
||||
|
||||
## Ending a lift
|
||||
|
||||
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
|
||||
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
|
||||
3. PM tags the release (only after explicit user `yes`)
|
||||
4. Ctrl-C the relay terminal — all in-memory messages are discarded
|
||||
|
||||
---
|
||||
|
||||
## Roles and boundaries (quick reference)
|
||||
|
||||
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
|
||||
|
||||
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
|
||||
|
||||
**User must:** authorize all merges and the release tag. Everything else is delegated.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Verification: 2026-04-18 Initial Security Audit
|
||||
|
||||
**Verified by:** Claude Opus 4.5
|
||||
**Date:** 2026-05-01
|
||||
**Methodology:** Code inspection of referenced file paths and line numbers
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Finding | File Exists | Lines Match Current | Vulnerability Status | Confidence |
|
||||
|---------|-------------|---------------------|---------------------|------------|
|
||||
| C1 - Setup web-accessible | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| C2 - Message router trusts all | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| C3 - Capture innerHTML XSS | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| C4 - Autofill no origin check | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| H1 - KDF unprefixed concat | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| H2 - Master key not zeroized | ✅ | ❌ refactored | **FIXED** | 9/10 |
|
||||
| H3 - Passphrase gate cosmetic | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| H4 - Git shells out unsafely | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| H5 - WASM Math.random() | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| H6 - Modulo bias | ✅ | ❌ refactored | **FIXED** | 10/10 |
|
||||
| H7 - rpassword outdated | ✅ | ✅ | **FIXED** | 10/10 |
|
||||
| H8 - Storage plaintext | ✅ | ⚠️ partial | **ACKNOWLEDGED** | 8/10 |
|
||||
|
||||
**Verdict:** All CRITICAL and HIGH findings except H8 have been remediated. The codebase has been significantly refactored since this audit, making line number references obsolete but confirming fixes were applied.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL Findings
|
||||
|
||||
### C1: Setup wizard web-accessible
|
||||
|
||||
- **Original claim:** `web_accessible_resources` with `matches: ["<all_urls>"]` allows any website to inject vault config.
|
||||
- **Current state:** `extension/manifest.json` line 38 shows `"web_accessible_resources": []` (empty array).
|
||||
- **Router validation:** `router/index.ts` lines 29-71 verify sender origins (`isPopup`, `isSetup`, `isContent`) and return `unauthorized_sender` for invalid callers.
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### C2: Service-worker trusts every message
|
||||
|
||||
- **Original claim:** `index.ts:116-441` ignores `_sender` and trusts all messages.
|
||||
- **Current state:** `service-worker/index.ts` is now 100 lines; message handling delegated to modular router.
|
||||
- **Router checks:**
|
||||
- `sender.frameId === 0` for content scripts (line 42)
|
||||
- `sender.id === chrome.runtime.id` (line 43)
|
||||
- Returns `{ ok: false, error: 'unauthorized_sender' }` for invalid senders
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### C3: Capture prompt innerHTML injection
|
||||
|
||||
- **Original claim:** `capture.ts:172-191` uses innerHTML with attacker-controlled strings in page DOM.
|
||||
- **Current state:**
|
||||
- Uses `createShadowHost()` (line 128) for closed Shadow DOM
|
||||
- Builds DOM via `document.createElement` + `.textContent =` (lines 143-216)
|
||||
- File header (lines 6-9) documents this pattern
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### C4: Autofill has no origin check
|
||||
|
||||
- **Original claim:** `get_autofill_candidates` accepts URL from message payload; `get_credentials` returns any entry by ID.
|
||||
- **Current state in `content-callable.ts`:**
|
||||
- Line 25: `const senderHost = safeHostname(sender.tab?.url ?? '')` — uses sender tab, not message
|
||||
- Line 44: `if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' }`
|
||||
- Lines 46-51: TOFU origin-ack check before returning credentials
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
---
|
||||
|
||||
## HIGH Findings
|
||||
|
||||
### H1: Argon2id unprefixed concatenation
|
||||
|
||||
- **Original claim:** `crypto.rs:225-227` has `password = passphrase || image_secret` without length prefix.
|
||||
- **Current state at `crypto.rs:229-236`:**
|
||||
```rust
|
||||
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
|
||||
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
|
||||
password.extend_from_slice(&nfc_passphrase);
|
||||
password.extend_from_slice(&32u64.to_be_bytes());
|
||||
password.extend_from_slice(image_secret);
|
||||
```
|
||||
Also includes NFC normalization (lines 224-227).
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### H2: Master key never zeroized
|
||||
|
||||
- **Original claim:** `Vec<u8>` from `derive_master_key` and intermediates leak into heap.
|
||||
- **Current state:**
|
||||
- `crypto.rs:212`: returns `Zeroizing<[u8; 32]>`
|
||||
- `crypto.rs:232`: password wrapped in `Zeroizing::new()`
|
||||
- `session.rs` (WASM/CLI): stores keys as `Zeroizing<[u8; 32]>`
|
||||
- CLI rpassword calls wrapped in `Zeroizing::new()`
|
||||
- **Note:** JS string zeroization remains a limitation (acknowledged).
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### H3: Passphrase strength gate cosmetic
|
||||
|
||||
- **Original claim:** Extension accepts any non-empty passphrase; CLI only requires 8 chars.
|
||||
- **Current state:**
|
||||
- `setup.ts:152,640`: `score < 3` disables button
|
||||
- `setup.ts:784-789`: server-side re-validation before create
|
||||
- `generators.rs:124-130`: `validate_passphrase_strength()` requires score >= 3
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### H4: Git shells out without guards
|
||||
|
||||
- **Original claim:** No hooks/gpgsign/editor isolation.
|
||||
- **Current state in `helpers.rs:41-55`:**
|
||||
```rust
|
||||
cmd.args([
|
||||
"-c", "core.hooksPath=/dev/null",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"-c", "core.editor=true",
|
||||
]);
|
||||
```
|
||||
Comment explicitly references "Audit H4".
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### H5: WASM Math.random()
|
||||
|
||||
- **Original claim:** `lib.rs:240-256` uses `Math.random()` for password generation.
|
||||
- **Current state:** `generate_password` calls `core_generate_password` from relicario-core.
|
||||
- **generators.rs:**
|
||||
- Line 6: `use rand::rngs::OsRng;`
|
||||
- Lines 61-64: Uses `Uniform::from()` with `OsRng`
|
||||
- No `Math.random()` anywhere in codebase
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### H6: Modulo bias
|
||||
|
||||
- **Original claim:** `main.rs:308-317` uses `% CHARSET.len()`.
|
||||
- **Current state in `generators.rs`:**
|
||||
- Line 61: `let dist = Uniform::from(0..charset.len());`
|
||||
- Line 63: `charset[dist.sample(&mut rng)]` — rejection sampling, no modulo
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### H7: rpassword 5.0.1 outdated
|
||||
|
||||
- **Original claim:** Uses deprecated `prompt_password_stderr`.
|
||||
- **Current state:** `Cargo.toml` shows `rpassword = "7"`, uses `prompt_password`.
|
||||
- **Status:** ✅ **FIXED**
|
||||
|
||||
### H8: Storage keeps apiToken/imageBase64 plaintext
|
||||
|
||||
- **Original claim:** `chrome.storage.local` stores PAT and reference image unencrypted.
|
||||
- **Current state:** Still true — `popup-only.ts:139-141` stores `vaultConfig` and `imageBase64`.
|
||||
- **Mitigation:** Acknowledged as design constraint; spec documents that filesystem access to browser profile compromises both factors.
|
||||
- **Status:** ⚠️ **ACKNOWLEDGED** (not fixed, documented as acceptable tradeoff)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The 2026-04-18 audit identified real vulnerabilities that existed at that time. **All CRITICAL and HIGH findings (C1-C4, H1-H7) have since been remediated** with the exact fixes recommended in the audit. The codebase underwent significant refactoring, making the original line number references obsolete.
|
||||
|
||||
H8 remains as an acknowledged design constraint inherent to Chrome extension architecture.
|
||||
|
||||
**The audit was accurate and the remediation was thorough.**
|
||||
@@ -1,4 +1,4 @@
|
||||
# relicario Security Audit Report
|
||||
# Relicario Security Audit Report
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Scope:** Full static review of `crates/relicario-core/`, `crates/relicario-cli/`, `crates/relicario-wasm/`, `extension/src/`, both manifests, both webpack configs, and the design spec at `docs/superpowers/specs/2026-04-11-relicario-design.md`.
|
||||
|
||||
113
docs/superpowers/audits/2026-05-01-security-audit-opus-4-5.md
Normal file
113
docs/superpowers/audits/2026-05-01-security-audit-opus-4-5.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Verification: 2026-05-01 Security Audit
|
||||
|
||||
**Verified by:** Claude Opus 4.5
|
||||
**Date:** 2026-05-01
|
||||
**Methodology:** Code inspection of referenced file paths and line numbers
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Finding | File Exists | Lines Accurate | Vulnerability Real | Confidence |
|
||||
|---------|-------------|----------------|-------------------|------------|
|
||||
| 1 - Backup KDF NFC | ✅ | ✅ | ✅ | 10/10 |
|
||||
| 2 - Commit injection | ✅ | ✅ | ✅ | 10/10 |
|
||||
| 3 - WASM private key exposure | ✅ | ✅ | ✅ | 10/10 |
|
||||
| 4 - Test env vars in prod | ✅ | ✅ | ✅ | 10/10 |
|
||||
| 5 - AttachmentId 64-bit | ✅ | ⚠️ off-by-1 | ✅ | 9/10 |
|
||||
| 6 - Field history plaintext | ✅ | ✅ | ✅ | 10/10 |
|
||||
| 7 - Device keys non-functional | ✅ | ✅ | ✅ | 10/10 |
|
||||
| 8 - Path traversal restore | ✅ | ✅ | ✅ | 10/10 |
|
||||
|
||||
**Verdict:** All 8 findings are verified as real vulnerabilities in the current codebase.
|
||||
|
||||
---
|
||||
|
||||
## Finding-by-Finding Verification
|
||||
|
||||
### Finding 1 — Backup KDF missing NFC normalization
|
||||
|
||||
- **File:** `crates/relicario-core/src/backup.rs`
|
||||
- **Claimed lines:** 303-312
|
||||
- **Verified:** ✅ `derive_backup_key` at lines 303-312 passes `passphrase` directly to `argon.hash_password_into()` without NFC normalization. Compare to `derive_master_key` in `crypto.rs:224-227` which explicitly normalizes.
|
||||
- **Impact confirmed:** Cross-platform restore failure for non-ASCII passphrases.
|
||||
|
||||
### Finding 2 — Commit message injection via item titles
|
||||
|
||||
- **File:** `crates/relicario-cli/src/main.rs`
|
||||
- **Claimed lines:** 565, 899-901, 1110, 1327
|
||||
- **Verified:** ✅
|
||||
- Line 565: `format!("add: {} ({})", item.title, item.id.as_str())`
|
||||
- Line 1110: `format!("edit: {} ({})", item.title, item.id.as_str())`
|
||||
- Line 1327: `format!("trash: {} ({})", item.title, item.id.as_str())`
|
||||
- **Impact confirmed:** Newlines/control chars in titles corrupt git log output.
|
||||
|
||||
### Finding 3 — WASM `generate_device_keypair` crosses private key to JS
|
||||
|
||||
- **File:** `crates/relicario-wasm/src/lib.rs`
|
||||
- **Claimed lines:** 215-227
|
||||
- **Verified:** ✅ Function returns `{ "private_key_base64": "..." }` as `JsValue`, exposing ed25519 private key to JavaScript heap.
|
||||
- **Impact confirmed:** Key material accessible to any JS in service worker context.
|
||||
|
||||
### Finding 4 — Test env vars ship in production binary
|
||||
|
||||
- **File:** `crates/relicario-cli/src/main.rs`
|
||||
- **Claimed lines:** 445-446, 421-423, 1425-1426
|
||||
- **Verified:** ✅
|
||||
- Lines 421-423: `RELICARIO_TEST_ITEM_SECRET`
|
||||
- Lines 445-446: `RELICARIO_TEST_PASSPHRASE`
|
||||
- Lines 1425-1426: `RELICARIO_TEST_BACKUP_PASSPHRASE`
|
||||
- **Impact confirmed:** All checked in production code without `#[cfg(test)]`. Passphrase visible in `/proc/<pid>/environ`.
|
||||
|
||||
### Finding 5 — `AttachmentId` truncated to 64 bits
|
||||
|
||||
- **File:** `crates/relicario-core/src/ids.rs`
|
||||
- **Claimed lines:** 52-57
|
||||
- **Actual lines:** 51-56 (off by 1)
|
||||
- **Verified:** ✅ `&digest[..8]` = 8 bytes = 64 bits. Birthday collision at ~2³² work.
|
||||
- **Impact confirmed:** Attacker with attachment upload can cause silent overwrites.
|
||||
|
||||
### Finding 6 — `get_field_history` returns plaintext to JS
|
||||
|
||||
- **File:** `crates/relicario-wasm/src/lib.rs`
|
||||
- **Claimed lines:** 232-265
|
||||
- **Verified:** ✅ Returns historical `Password`/`Concealed` values as plaintext JSON via `v.as_str().to_owned()`.
|
||||
- **Impact confirmed:** Password history exposed to JS heap without Zeroizing.
|
||||
|
||||
### Finding 7 — Device key system is security theater
|
||||
|
||||
- **File:** `crates/relicario-cli/src/main.rs`
|
||||
- **Claimed lines:** 2151-2221
|
||||
- **Verified:** ✅ `cmd_device()` handles Add/List/Revoke but:
|
||||
- No `sign_commit` or `verify_signature` functions exist anywhere
|
||||
- `devices.json` is plaintext and unauthenticated
|
||||
- Revocation has no enforcement mechanism
|
||||
- **Impact confirmed:** Users falsely believe device revocation provides security.
|
||||
|
||||
### Finding 8 — Path traversal on backup restore
|
||||
|
||||
- **File:** `crates/relicario-cli/src/main.rs`
|
||||
- **Claimed lines:** 1619-1626
|
||||
- **Verified:** ✅
|
||||
```rust
|
||||
for item in &unpacked.items {
|
||||
fs::write(target.join("items").join(format!("{}.enc", item.id)), ...)?;
|
||||
}
|
||||
```
|
||||
`item.id` and `attachment_id` used directly in path construction with no validation.
|
||||
- **Impact confirmed:** Crafted `.relbak` with `id = "../../.bashrc"` escapes target directory.
|
||||
|
||||
---
|
||||
|
||||
## Blockers Assessment
|
||||
|
||||
The audit's "Path to Certifiable Safety" section is accurate:
|
||||
|
||||
| Blocker | Verified | Severity |
|
||||
|---------|----------|----------|
|
||||
| B1 - Device key theater | ✅ Real | High |
|
||||
| B2 - Backup KDF NFC | ✅ Real | Medium |
|
||||
| B3 - Test env vars | ✅ Real | Medium |
|
||||
| B4 - Path traversal | ✅ Real | Medium |
|
||||
|
||||
All four blockers are confirmed. B1 is the most dangerous as it misleads users about their security posture.
|
||||
199
docs/superpowers/audits/2026-05-01-security-audit.md
Normal file
199
docs/superpowers/audits/2026-05-01-security-audit.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Relicario Security Audit — 2026-05-01
|
||||
|
||||
Scope: full project audit (not a PR diff). Covers crypto correctness, protocol gaps,
|
||||
implementation reality vs. plans, and a roadmap toward third-party auditability.
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — Security Findings
|
||||
|
||||
### Finding 1 — Backup KDF missing NFC normalization
|
||||
|
||||
**`crates/relicario-core/src/backup.rs:303-312`** · Severity: **Medium**
|
||||
|
||||
`derive_backup_key` passes raw passphrase bytes to Argon2id. The main vault KDF in
|
||||
`crypto.rs` uses `u64_be(len) || nfc_passphrase || u64_be(32) || image_secret`. The
|
||||
backup KDF has neither NFC normalization nor the length-prefix construction.
|
||||
|
||||
**Exploit:** User creates a backup on macOS (NFD normalization) and restores on Linux
|
||||
(NFC). The Argon2id input differs → wrong key → unrestorable backup. Affects any
|
||||
non-ASCII passphrase (`"Crêpe-7"`, `"café"`, accented chars).
|
||||
|
||||
**Fix:** Factor out `normalize_passphrase()` and use it in both `derive_master_key` and
|
||||
`derive_backup_key`.
|
||||
|
||||
---
|
||||
|
||||
### Finding 2 — Commit message injection via item titles
|
||||
|
||||
**`crates/relicario-cli/src/main.rs:565, 899-901, 1110, 1327`** · Severity: **Medium**
|
||||
|
||||
Item titles (arbitrary user UTF-8) are embedded directly into `-m` commit message strings
|
||||
via `format!("add: {} ({})", item.title, ...)`. `git_command` uses `Command::args()` (no
|
||||
shell), so shell injection into `git add` is blocked — but newlines in titles produce
|
||||
malformed multi-line commit messages that corrupt git log parsers.
|
||||
|
||||
**Fix:** Strip control characters from titles before embedding in commit messages, or omit
|
||||
the title from the `-m` format entirely and use only the item ID.
|
||||
|
||||
---
|
||||
|
||||
### Finding 3 — WASM `generate_device_keypair` crosses private key bytes to JS
|
||||
|
||||
**`crates/relicario-wasm/src/lib.rs:215-227`** · Severity: **Medium**
|
||||
|
||||
Returns `{ "private_key_base64": "..." }` as a `JsValue`. The ed25519 private key lives in
|
||||
the JS heap with no `Zeroizing` protection. The vault master key is protected behind an
|
||||
opaque `SessionHandle` and never crosses to JS — the device key has no such protection.
|
||||
|
||||
**Exploit:** Any JS running in the extension service worker context (compromised dependency,
|
||||
content script escalation) that can intercept the return value gets the raw device key.
|
||||
|
||||
**Fix:** Never return the private key to JS. Expose only a `sign(handle, data) → signature`
|
||||
API; perform the signing in Rust.
|
||||
|
||||
---
|
||||
|
||||
### Finding 4 — Test env vars ship in production binary
|
||||
|
||||
**`crates/relicario-cli/src/main.rs:445-446, 421-423, 1425-1426`** · Severity: **Medium**
|
||||
|
||||
`RELICARIO_TEST_PASSPHRASE`, `RELICARIO_TEST_ITEM_SECRET`, `RELICARIO_TEST_BACKUP_PASSPHRASE`
|
||||
are checked in production code (not `#[cfg(test)]`). When set, they bypass the interactive
|
||||
TTY prompt.
|
||||
|
||||
**Exploit:** On Linux, `/proc/<pid>/environ` exposes the passphrase in cleartext to
|
||||
same-UID processes. Shell history captures `RELICARIO_TEST_PASSPHRASE=mysecret relicario unlock ...`.
|
||||
|
||||
**Fix:** Gate behind `#[cfg(test)]` or a `--features testing` build profile.
|
||||
|
||||
---
|
||||
|
||||
### Finding 5 — `AttachmentId` truncated to 64 bits of SHA-256
|
||||
|
||||
**`crates/relicario-core/src/ids.rs:52-57`** · Severity: **Medium**
|
||||
|
||||
`AttachmentId::from_plaintext` takes `&digest[..8]` (8 bytes = 64 bits). Standard
|
||||
content-addressed stores use ≥128 bits. With 64 bits, an attacker who can supply attachment
|
||||
content can find a second-preimage collision with ~2^32 work, causing a crafted attachment
|
||||
to silently overwrite an existing one on disk.
|
||||
|
||||
**Fix:** Change `&digest[..8]` → `&digest[..16]` (128 bits). No migration needed for
|
||||
existing vaults since only new attachments are affected.
|
||||
|
||||
---
|
||||
|
||||
### Finding 6 — `get_field_history` re-parses item JSON from JS heap
|
||||
|
||||
**`crates/relicario-wasm/src/lib.rs:232-265`** · Severity: **Medium**
|
||||
|
||||
Returns all historical `Password`/`Concealed` values as plaintext `JsValue`. The values
|
||||
are regular `String` allocations with no `Zeroizing` wrapper before serialization into
|
||||
`serde_json::Value`.
|
||||
|
||||
**Fix:** Architectural — document that the caller must treat the return value as sensitive.
|
||||
For strong hygiene: do all history display in Rust, never returning password history bytes
|
||||
to JS.
|
||||
|
||||
---
|
||||
|
||||
### Finding 7 — Device key system is non-functional as a security control
|
||||
|
||||
**`crates/relicario-cli/src/main.rs:2151-2221`** · Severity: **High**
|
||||
|
||||
`device add/list/revoke` and `generate_device_keypair` exist, but **no code anywhere signs
|
||||
git commits with device keys**, and **no code verifies device signatures**. `devices.json`
|
||||
is plaintext in the repo and unauthenticated by the vault.
|
||||
|
||||
**Exploit:** Users believe "device revocation" prevents unauthorized access after a device
|
||||
is stolen/compromised. It does nothing. A stolen device continues to have full vault access
|
||||
via its git remote credentials regardless of revocation.
|
||||
|
||||
**Fix:** Either (a) implement commit signing + server-side pre-receive hook verification, or
|
||||
(b) remove the `device` subcommands and document that access control is SSH-key-level only.
|
||||
|
||||
---
|
||||
|
||||
### Finding 8 — Path traversal on backup restore
|
||||
|
||||
**`crates/relicario-cli/src/main.rs:1619-1626`** · Severity: **Medium**
|
||||
|
||||
During restore, item/attachment IDs from the decrypted backup JSON are used directly as
|
||||
path components with no format validation. IDs are AEAD-authenticated but a user restoring
|
||||
from a crafted `.relbak` with a known passphrase would execute arbitrary path writes.
|
||||
|
||||
**Exploit (social engineering):** Attacker provides a `.relbak` with item ID
|
||||
`../../.bashrc` → restore overwrites `~/.bashrc`.
|
||||
|
||||
**Fix:**
|
||||
```rust
|
||||
ensure!(id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"invalid id in backup");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — Implementation Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Two-factor decrypt (passphrase + image_secret) | ✅ Implemented | Full crypto pipeline, NFC passphrase, Argon2id m=64MiB t=3 p=4 |
|
||||
| imgsecret embed | ✅ Implemented | DCT QIM, QUANT_STEP=50, central 70%, 5-50 redundant copies |
|
||||
| imgsecret extract + crop recovery | ✅ Implemented | Majority voting ≥60%, 4 crop search strategies |
|
||||
| Manifest browse (schema v2) | ✅ Implemented | Encrypted with master key, search(), title/type/tags/icon |
|
||||
| Vault CRUD (init/add/edit/rm/trash/restore/purge) | ✅ Implemented | All 7 item types fully handled |
|
||||
| CLI `init` | ✅ Implemented | zxcvbn ≥3 gate, image embed, Argon2id params, git init |
|
||||
| CLI `add` / `edit` | ✅ Implemented | All 7 types, TOTP QR decode via rqrr, field history capture |
|
||||
| CLI `generate` | ✅ Implemented | Random (rejection-sampled) + BIP39, uses vault defaults |
|
||||
| CLI `sync` | ✅ Implemented | `git pull --rebase && git push` |
|
||||
| CLI `backup export/restore` | ✅ Implemented | Plan 3A: zstd+AEAD container, optional image + git bundle |
|
||||
| CLI `import lastpass` | ✅ Implemented | Plan 3B: CSV validation, Login + SecureNote + TOTP mapping |
|
||||
| WASM bindings (all item/manifest/settings) | ✅ Implemented | Complete symmetric set |
|
||||
| WASM session handle (opaque master key) | ✅ Implemented | Key never crosses WASM boundary |
|
||||
| WASM attachment, generator, TOTP, backup, import | ✅ Implemented | All wired |
|
||||
| Field history tracking + CLI `history` | ✅ Implemented | Password/Concealed/TOTP history, prune policies |
|
||||
| Trash + retention | ✅ Implemented | `trash list/empty`, TrashRetention window |
|
||||
| Attachments (CLI + WASM) | ✅ Implemented | File-level AEAD, cap enforcement, Document type |
|
||||
| Settings / VaultSettings | ✅ Implemented | All retention + generator + cap fields, CLI subcommands |
|
||||
| Device keys (add/list/revoke) | ⚠️ Partial | Key gen + persistence only — **no signing, no verification** (Finding 7) |
|
||||
| Per-vault total attachment cap | ⚠️ Partial | Cap defined in settings, per-attachment enforced — per-vault total bytes not checked |
|
||||
| Browser extension UI | ⚠️ Partial | WASM surface complete; extension TypeScript/HTML is a separate repo |
|
||||
| Recovery QR | ❌ Plan-only | Spec written; no `recovery_qr.rs` module exists |
|
||||
| Password coloring | ❌ Plan-only | Spec written; no implementation |
|
||||
| Passphrase rotation | ❌ Deferred | Explicitly back-burnered |
|
||||
| Pre-v0.3.0 audit walk | ❌ Not started | Listed as pending before v0.3.0 tag |
|
||||
| HOTP counter persistence | ❌ Bug | `Hotp { counter }` never incremented/saved — HOTP desynchronizes immediately |
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — Path to Certifiable Safety
|
||||
|
||||
### Blockers — must fix before any real use
|
||||
|
||||
| # | Item |
|
||||
|---|---|
|
||||
| B1 | **Device key system is security theater** — implement signing or remove the commands. This is the most dangerous finding because it misleads users about their security posture. |
|
||||
| B2 | **Backup KDF NFC normalization** — one-line fix; data loss risk for non-ASCII passphrases. |
|
||||
| B3 | **Test env vars in production binary** — gate with `#[cfg(test)]`. Exposes passphrase via `/proc`. |
|
||||
| B4 | **Path traversal on restore** — two-line ID validation before any `fs::write`. |
|
||||
|
||||
### Important — fix before third-party audit
|
||||
|
||||
| # | Item |
|
||||
|---|---|
|
||||
| I1 | Sanitize item titles before embedding in commit messages |
|
||||
| I2 | `AttachmentId`: `&digest[..8]` → `&digest[..16]` (128-bit collision resistance) |
|
||||
| I3 | Enforce per-vault total attachment bytes cap (already defined, never checked) |
|
||||
| I4 | Document manifest integrity model: AEAD protects against silent modification, but item deletion is only detectable via git history |
|
||||
| I5 | Stop crossing device private key bytes to JS (prerequisite for B1 if signing is implemented) |
|
||||
| I6 | Fix HOTP counter: increment + re-save on each `totp get`, or disable HOTP and return an error |
|
||||
|
||||
### Nice-to-have — audit-friendliness
|
||||
|
||||
| # | Item |
|
||||
|---|---|
|
||||
| N1 | Wrap `nfc_passphrase: Vec<u8>` in `Zeroizing` in `derive_master_key` |
|
||||
| N2 | `cargo audit` in CI |
|
||||
| N3 | Validate Argon2id params on vault load — warn if below production minimums |
|
||||
| N4 | Broaden steganography recompression tests to use ImageMagick/libjpeg-turbo (not just the `image` crate) |
|
||||
| N5 | Consider machine-readable audit log encrypted alongside the vault |
|
||||
178
docs/superpowers/audits/2026-05-02-doc-audit.md
Normal file
178
docs/superpowers/audits/2026-05-02-doc-audit.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Documentation Audit — 2026-05-02
|
||||
|
||||
Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total findings:** 14
|
||||
- **Fixed inline (initial pass):** 6
|
||||
- **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
|
||||
- **No action needed:** 0
|
||||
- **Top 3 recommendations:**
|
||||
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
|
||||
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
|
||||
3. **Rewrite the `docs/ARCHITECTURE.md` "Crate Architecture" + "Vault Creation Flow" sections** so they describe the v0.5.0 surface (typed items, settings.enc, device auth boundary, server crate, extension WASM) rather than the v0.1.0 freeze (Finding 10).
|
||||
|
||||
The codebase itself is well-documented — `crates/{relicario-core,relicario-cli}/ARCHITECTURE.md`, `extension/ARCHITECTURE.md`, and `docs/architecture/overview.md` are detailed and current. The drift is concentrated in **the top-level entry-point docs** (`README.md`, `CLAUDE.md`, `docs/ARCHITECTURE.md`) and in the SECURITY.md / overview.md edges.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### Finding 1 — `relicario-server` crate is invisible in cross-codebase docs
|
||||
|
||||
**File:** `docs/architecture/overview.md` (lines 14–48 — "The three codebases" + table)
|
||||
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
|
||||
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed in `ca059e7` (PM follow-up, 2026-05-02): "four codebases" framing, ASCII diagram fans core out to cli + server + wasm, table row added, build matrix gains `cargo build -p relicario-server`, "Where to look next" points at server src + design spec.
|
||||
|
||||
---
|
||||
|
||||
### Finding 2 — `CLAUDE.md` project-structure tree omits `relicario-server`
|
||||
|
||||
**File:** `CLAUDE.md` (lines 26–54)
|
||||
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
|
||||
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): added `relicario-server/` entry to project tree.
|
||||
|
||||
---
|
||||
|
||||
### Finding 3 — `CLAUDE.md` Roadmap is severely stale
|
||||
|
||||
**File:** `CLAUDE.md` (lines 91–93)
|
||||
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
|
||||
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): replaced with the v0.5.0 / Phases 3-4 / 1C-γ / LastPass / mobile / recovery-QR picture.
|
||||
|
||||
---
|
||||
|
||||
### Finding 4 — `CLAUDE.md` says "Item IDs are random 8-char hex"
|
||||
|
||||
**File:** `CLAUDE.md` (line 79)
|
||||
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
|
||||
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): now reads "Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits)."
|
||||
|
||||
---
|
||||
|
||||
### Finding 5 — `docs/architecture/overview.md` conventions table also says "8-char hex"
|
||||
|
||||
**File:** `docs/architecture/overview.md` (line 180)
|
||||
**Issue:** Same M8 bump; the conventions table at line 180 said `Item IDs are random 8-char hex`.
|
||||
**Fix:** Update to 16-char hex / 64 bits, and bump the AttachmentId row to mention the 128-bit width.
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed inline in `docs/architecture/overview.md`
|
||||
|
||||
---
|
||||
|
||||
### Finding 6 — `README.md` uses obsolete `entries/` directory layout
|
||||
|
||||
**File:** `README.md` (lines 36, 117–118, 147–149)
|
||||
**Issue:** References to `entries/*.enc` and the `entry.rs` module are pre-typed-items vocabulary. The on-disk layout is now `items/<id>.enc` + `attachments/<item-id>/<aid>.enc` + `settings.enc`; the core module is `item.rs` + `item_types/`. The README is the security proof and the first thing visitors read — getting the on-disk shape wrong hurts the legibility-as-security pitch.
|
||||
**Fix:** Replace `entries/` with `items/`. Add `settings.enc`, `attachments/<item-id>/<aid>.enc`, and (for device-auth) `revoked.json`. Rewrite the `crates/` tree to match the actual seven-module shape and add `relicario-wasm` and `relicario-server`. Update item-ID width to 16-char hex.
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed inline in `README.md`
|
||||
|
||||
---
|
||||
|
||||
### Finding 7 — `README.md` Roadmap lists shipped features as upcoming
|
||||
|
||||
**File:** `README.md` (lines 184–192)
|
||||
**Issue:** All of these are checked off in real life but unchecked in the doc: WASM/Chrome extension, secure notes, secure document storage, LastPass import, Firefox extension. Only Bitwarden/1Password import, the unlock daemon, mobile, and Safari are still unstarted.
|
||||
**Fix:** Mark the shipped items as `[x]`; add Firefox WebExtension, typed items, backup/restore, LastPass CSV import, and device authentication as completed; keep Bitwarden/1Password import, unlock daemon, mobile, Safari as open.
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed inline in `README.md`
|
||||
|
||||
---
|
||||
|
||||
### Finding 8 — `docs/ARCHITECTURE.md` ASCII vault layout uses `entries/` and lacks settings/attachments/revoked
|
||||
|
||||
**File:** `docs/ARCHITECTURE.md` (lines 45–53)
|
||||
**Issue:** Same staleness as README Finding 6. The "GIT SERVER (untrusted)" box shows only `manifest.enc` + `entries/<id>.enc` + `.relicario/{salt,params.json,devices.json}`. Missing: `settings.enc`, `attachments/<item-id>/<aid>.enc`, `revoked.json`. ID lengths are 8-char hex (`a1b2c3d4`) instead of 16-char hex.
|
||||
**Fix:** Update box to current layout including settings.enc, attachments tree, revoked.json, and 16-char IDs.
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed inline in `docs/ARCHITECTURE.md`
|
||||
|
||||
---
|
||||
|
||||
### Finding 9 — `docs/ARCHITECTURE.md` "Crate Architecture" omits wasm + server crates
|
||||
|
||||
**File:** `docs/ARCHITECTURE.md` (lines 208–235)
|
||||
**Issue:** The bottom box of the "Crate Architecture" diagram says `Future: relicario-wasm wraps this for browser extension` and `Future: JNI/Swift wrappers for Android/iOS`. WASM is no longer future — it shipped. The `relicario-server` crate isn't mentioned at all. The `relicario-core` module list inside the box still says `entry / Manifest / search`, predating the typed-items rewrite (`item`, `item_types/`, `settings`, `attachment`, `backup`, `device`).
|
||||
**Fix:** Replace the inner-box module names with the current set; remove the "Future: relicario-wasm" line and add a "Consumed by" line listing all three downstream crates including server.
|
||||
**Severity:** must-fix-before-v0.5.0
|
||||
**Status:** Fixed inline in `docs/ARCHITECTURE.md`
|
||||
|
||||
---
|
||||
|
||||
### Finding 10 — `docs/ARCHITECTURE.md` "Vault Creation Flow" doesn't reflect typed-items or settings.enc
|
||||
|
||||
**File:** `docs/ARCHITECTURE.md` (lines 60–89)
|
||||
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
|
||||
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
|
||||
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
|
||||
**Status:** Fixed in `76d092d` (PM follow-up, 2026-05-02): trim path. Added settings.enc as a parallel artifact in the encrypt step, then a short paragraph pointing at `crates/relicario-core/ARCHITECTURE.md` for the per-item lifecycle.
|
||||
|
||||
---
|
||||
|
||||
### Finding 11 — `docs/SECURITY.md` "Device registration was optional before v0.4.0" is undated/misleading
|
||||
|
||||
**File:** `docs/SECURITY.md` (lines 60–62)
|
||||
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
|
||||
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
|
||||
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
|
||||
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02 with user approval): dropped the "before v0.4.0" version line entirely (v0.4.0 was never tagged); replaced with a single line saying registration is optional but recommended for shared vaults. Enforcement story now lives in the Device Authentication section (see F12).
|
||||
|
||||
---
|
||||
|
||||
### Finding 12 — `docs/SECURITY.md` doesn't mention `relicario-server`
|
||||
|
||||
**File:** `docs/SECURITY.md` (lines 44–51)
|
||||
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
|
||||
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
|
||||
**Severity:** nice-to-have
|
||||
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02): added paragraph naming the `relicario-server` crate, both subcommands (`generate-hook`, `verify-commit`), and the caveat that signed commits without the server hook provide authorship metadata only.
|
||||
|
||||
---
|
||||
|
||||
### Finding 13 — Foundational design spec's "Post-V1 Ideas" lists shipped features
|
||||
|
||||
**File:** `docs/superpowers/specs/2026-04-11-relicario-design.md` (lines 351–361)
|
||||
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
|
||||
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
|
||||
**Severity:** informational
|
||||
**Status:** Fixed in `9c97f9f` (PM follow-up, 2026-05-02): added the optional one-line status banner at the top of the spec pointing at CHANGELOG.md and overview.md for current state. Body of the spec untouched per the "specs are frozen decision artifacts" convention.
|
||||
|
||||
---
|
||||
|
||||
### Finding 14 — Lowercase "relicario" in prose contexts
|
||||
|
||||
**File:** `README.md` (line 67), `docs/ARCHITECTURE.md` (none found in prose), `docs/architecture/overview.md` (none found in prose), `docs/SECURITY.md` (none found in prose)
|
||||
**Issue:** Per CLAUDE.md, "Relicario" should be capitalized in prose. A search across the audit-scope docs finds no uppercase-violations — most prose lowercase usages are in code paths (`.relicario/`, `relicario init`, `relicario-core`) which are correctly lowercase per the rule. The README at line 67 ("Relicario generates unique passwords per site") is correctly capitalized; line 26 ("Relicario embeds a random 256-bit secret") is correct. **No lowercase prose occurrences found.** This finding is "checked, no action needed."
|
||||
**Fix:** N/A
|
||||
**Severity:** informational
|
||||
**Status:** No action needed (recorded for completeness)
|
||||
|
||||
---
|
||||
|
||||
## Inline-fix verification
|
||||
|
||||
### Initial pass (commit `900ccf1`):
|
||||
|
||||
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
|
||||
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
|
||||
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
|
||||
|
||||
### v0.5.0 PM follow-up pass (commits `ca059e7`, `8fd9a05`, `1342228`, `76d092d`, `9c97f9f`):
|
||||
|
||||
- `docs/architecture/overview.md` — F1: four-codebases framing, ASCII diagram fans out to server, table row, build matrix, "Where to look next".
|
||||
- `CLAUDE.md` — F2: project tree gains `relicario-server`. F3: Roadmap line replaced. F4: Item/Field/Attachment ID widths and entropy noted.
|
||||
- `docs/SECURITY.md` — F11: dropped `before v0.4.0` line. F12: Device Authentication section now names the `relicario-server` crate and its subcommands, with the "without the hook, commits are advisory" caveat.
|
||||
- `docs/ARCHITECTURE.md` — F10: settings.enc shown alongside manifest.enc in the Vault Creation Flow; pointer added to per-crate ARCHITECTURE.md for typed-items detail.
|
||||
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — F13: optional one-line "historical spec" status banner at top.
|
||||
|
||||
No source files, `Cargo.lock`, or extension code were modified at any point.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Dev A Kickoff Prompt — arch-followup Plan A
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan A for the arch-followup "architecture-review followups" release train.
|
||||
|
||||
Plan A is the **security & docs polish PR** — the small one that ships first, in under a day, with no dependencies on Plans B or C. It closes the only defense-in-depth crypto gap the architecture review flagged (`SessionHandle` has no `impl Drop`, so wasm-bindgen's `.free()` is a cleanup no-op while the master key sits in WASM linear memory), removes the JS-side error swallow that was masking that fact, brings `recovery_qr.rs` documentation up to the density of `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs`, and finishes the relay-launcher dev-c expansion. Four phases, all S-effort.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-B (CLI restructure) and Dev-C (extension restructure). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario-plan-a -b feature/2026-05-04-a-security-polish
|
||||
cd ../relicario-plan-a
|
||||
pwd # should print /home/alee/Sources/relicario-plan-a (or similar absolute path)
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario-plan-a`**. Force-cd subagents into this directory — the project's `CLAUDE.md` memory rule explicitly requires that subagent prompts MUST start with `cd /home/alee/Sources/relicario-plan-a` so subagents don't accidentally commit to main. This is non-negotiable.
|
||||
|
||||
Today: 2026-05-04. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-a"}'
|
||||
```
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.1, P1.7, P1.8 + the partner JS-side fix at `service-worker/session.ts:26` + the `start.sh` launcher follow-up only**)
|
||||
3. `docs/superpowers/specs/2026-05-04-security-polish-design.md` — your plan, execute phase by phase
|
||||
|
||||
You do NOT need to read Plan B or Plan C in detail. Skim Plan B's "WASM/extension parity seam" paragraph (Phase 8) and Plan C's "Risks → `.free()` callsite policy" paragraph only if a coordination question arises — they're the points where Plans B/C reference your work.
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (per project memory's default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario-plan-a
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Phase 1 (Rust `impl Drop for SessionHandle` + wasm-bindgen-test + native fallback test), Phase 2 (JS `.free()` audit + remove the `try { current.free() }` swallow at `extension/src/service-worker/session.ts:26`), Phase 3 (`recovery_qr.rs` documentation pass — module-level `//!`, ASCII layout diagram, doc-comments on the four public items, `production_params` rationale or `const`), Phase 4 (`tools/relay/start.sh` dev-c expansion + verification that `queue.test.ts:54` is already fixed).
|
||||
|
||||
**Out of scope:** anything in Plan B (CLI restructure — `cli/main.rs` split, `git_run` helper, parser migration to core, etc.) or Plan C (extension restructure — `setup.ts` SW migration, `vault.ts` split, `StateHost` typing, SW router dedup). The other P2 WASM cleanups (double-lookup, Vec<u8> clone, naming inconsistency, concurrency primitive split). DEV-C's other relay P2s (queue TTL, `call.py`/`call.ts` tracking decision). The 8 "Open architectural decisions". If you trip over an out-of-scope issue or a new bug while doing your work, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- This is a defense-in-depth crypto fix. Do NOT skip Phase 1's `wasm-bindgen-test` AND the native `#[test]` fallback — both are required by the plan's Done criteria.
|
||||
- Do NOT remove the `try { current.free() }` swallow (Phase 2) until Phase 1's `impl Drop` has landed in the same branch — Phase 2 explicitly depends on Phase 1.
|
||||
- Phase 3 must produce documentation density that visibly matches `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs`. If your doc-block is shorter than `crypto.rs`'s top comment, it's not done.
|
||||
- Phase 4: confirm `queue.test.ts:54` is already fixed in commit `061facd` (read the file; assert the line is `assert.ok(isRole("dev-c"));`). If it isn't, escalate via `## QUESTION TO PM` — the plan was drafted assuming it was fixed.
|
||||
- The plan's Done criteria includes recording the `grep -rn "\.free\b" extension/src/` output in the PR description as the audit deliverable. Do NOT merge the PR without that grep recorded.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of multiple terminals. The relay routes messages between them.
|
||||
|
||||
**At every phase boundary** (complete, blocked, or question): call `read_messages(for="dev-a")` first, then post your update via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
|
||||
Branch: feature/2026-05-04-a-security-polish
|
||||
Task: <phase number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-A
|
||||
Time: <iso8601>
|
||||
Context: <what phase, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM via relay (or relayed by user if relay is down). Acknowledge and act.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute phase-to-phase per the plan
|
||||
- Make implementation decisions consistent with the plan and synthesis
|
||||
- Choose between the plan's optional micro-cleanups in Phase 3 (the named-constant slice ranges, the `recovery_kdf_input` length-prefix comment) — proceed if they make the code clearer
|
||||
- Decide whether to add `wasm.lock(handle)` before `.free()` in `clearCurrent()` (Phase 2 leaves this to your judgment; the plan recommends just `free()` post-Phase-1)
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- You discover the Rust toolchain is older than 1.79 (Phase 3 falls back to a runtime `#[test]` for the layout assertion — confirm with PM before switching)
|
||||
- You discover the `.free()` audit grep returns more than one match (the plan was drafted expecting one; investigate and report)
|
||||
- You discover `queue.test.ts:54` is NOT actually fixed (Phase 4 falls back to a real code change)
|
||||
- You discover the `start.sh` launcher requires deeper changes than the four locations the plan describes
|
||||
- A test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- Anything destructive (per project rules)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
# From the worktree root (/home/alee/Sources/relicario-plan-a):
|
||||
cargo test -p relicario-core
|
||||
cargo test -p relicario-wasm
|
||||
cd extension && npm test && cd ..
|
||||
|
||||
# Audit deliverable (record output in PR description):
|
||||
grep -rn "\.free\b" extension/src/
|
||||
|
||||
# Optional but recommended for the wasm-bindgen-test (requires wasm-pack):
|
||||
# wasm-pack test --node crates/relicario-wasm
|
||||
|
||||
# Manual launcher checks (Phase 4):
|
||||
bash tools/relay/start.sh --manual # confirm "Open 4 new terminals" + Dev-C prompt path
|
||||
# bash tools/relay/start.sh --kitty # if on a kitty terminal: confirm 4 tabs open
|
||||
```
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/2026-05-04-a-security-polish
|
||||
gh pr create --base main --head feature/2026-05-04-a-security-polish --title "docs+fix(arch-followup): Drop impl + recovery_qr docs + relay launcher (Plan A)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Architecture-review followup Plan A (security & docs polish). Source: `docs/superpowers/specs/2026-05-04-security-polish-design.md`.
|
||||
|
||||
- **P1.1** — `SessionHandle` now has `impl Drop` so wasm-bindgen's `.free()` actually clears the master key from WASM linear memory. Native + wasm-bindgen tests cover construct → drop → registry-empty.
|
||||
- **JS partner fix** — removed the `try { current.free() }` swallow at `extension/src/service-worker/session.ts:26`. Crypto-state-transition errors now propagate.
|
||||
- **P1.7** — `crates/relicario-core/src/recovery_qr.rs` documentation pass: module-level `//!`, ASCII layout diagram, doc-comments on the four public items, `production_params` parameter-pinning rationale.
|
||||
- **P1.8** — confirmed `tools/relay/queue.test.ts:54` already matches the new role union (committed in `061facd`); `tools/relay/start.sh` extended to discover and launch a fourth Dev-C window in `--manual`, `--tmux`, `--kitty` modes.
|
||||
|
||||
## Audit deliverables
|
||||
|
||||
`.free()` callsites under `extension/src/` (recorded for future regression baseline):
|
||||
|
||||
```
|
||||
<paste the grep output here>
|
||||
```
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] `cargo test -p relicario-core` passes (recovery_qr tests still green; layout static assertion compiles)
|
||||
- [ ] `cargo test -p relicario-wasm` passes including new `Drop` test
|
||||
- [ ] `cd extension && npm test` passes including new `clearCurrent()` test
|
||||
- [ ] `grep -rn "\.free\b" extension/src/` returns exactly one match (the SW callsite)
|
||||
- [ ] `bash tools/relay/start.sh --manual` shows "Open 4 new terminals" and lists the Dev-C prompt path
|
||||
|
||||
## Done criteria
|
||||
|
||||
Per `docs/superpowers/specs/2026-05-04-security-polish-design.md` Done criteria — every checkbox.
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL (post via `post_message`).
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created at `/home/alee/Sources/relicario-plan-a`, plan absorbed, on `feature/2026-05-04-a-security-polish`). Post it via `post_message(from="dev-a", to="pm", kind="status", body="...")`. Then start Phase 1 of your plan (Rust `impl Drop for SessionHandle` + tests).
|
||||
@@ -0,0 +1,207 @@
|
||||
# Dev B Kickoff Prompt — arch-followup Plan B
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan B for the arch-followup "architecture-review followups" release train.
|
||||
|
||||
Plan B is the **CLI restructure** — the "single biggest readability lift" per the synthesis. M-L effort, multi-day. It splits `crates/relicario-cli/src/main.rs` (2641 LOC) into a `commands/` folder + `prompt.rs` + `parse.rs`, then layers on the duplicated git-error UX consolidation, manifest-after-mutation cache discipline, `ParamsFile` dedup, batched purge, and the migration of pure parsers (and a third copy of base32) into `relicario-core` with WASM re-exports. Eight phases.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-A (security & docs polish) and Dev-C (extension restructure). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario-plan-b -b feature/2026-05-04-b-cli-restructure
|
||||
cd ../relicario-plan-b
|
||||
pwd # should print /home/alee/Sources/relicario-plan-b (or similar absolute path)
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario-plan-b`**. Force-cd subagents into this directory — the project's `CLAUDE.md` memory rule explicitly requires that subagent prompts MUST start with `cd /home/alee/Sources/relicario-plan-b` so subagents don't accidentally commit to main. This is non-negotiable.
|
||||
|
||||
Today: 2026-05-04. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-b"}'
|
||||
```
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules (Spanish flourish in chat replies only, capitalization, autonomy defaults, CLI/extension parity philosophy)
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.2, P1.3, P1.10 + the in-scope CLI P2s only**)
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — your plan, execute phase by phase
|
||||
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (your primary source for line-level context — the synthesis abbreviates)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read **only** the "Boundary notes for DEV-B" section near the end (cross-boundary contracts you must respect when designing the WASM exports for Phase 8)
|
||||
|
||||
You do NOT need to read Plan A or Plan C in detail. If a coordination question arises, skim Plan C's "Risks → WASM boundary coordination" paragraph (it cites your Phase 8 explicitly).
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (per project memory's default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario-plan-b
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
**Sequencing matters.** Phase 1 (the mechanical `main.rs` split) is the precondition for every other phase — phases 2-6 touch files that don't exist yet until phase 1 lands. Do NOT start phase 2 until phase 1's `cargo test --workspace` is green and a checkpoint commit is in place.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- Phase 1 — Mechanical split of `main.rs` into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr,init,generate,rate}.rs` + `prompt.rs` + `parse.rs`
|
||||
- Phase 2 — `helpers::git_run` + sweep of the 16 `bail!("git X failed")` sites
|
||||
- Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression
|
||||
- Phase 4 — `Vault::after_manifest_change` + sweep of the 7 `refresh_groups_cache` sites
|
||||
- Phase 5 — Single canonical `ParamsFile` (one definition shared between init writer and unlock reader)
|
||||
- Phase 6 — Batched purge in `cmd_purge` and `cmd_trash_empty` (3 git invocations for an N-item purge instead of 3N)
|
||||
- Phase 7 — Migrate `parse_month_year` / `base32_decode_lenient` / `guess_mime` to `relicario-core` + `pub(crate) mod base32` (closes DEV-A's three-base32-impls finding)
|
||||
- Phase 8 — WASM exports for the migrated parsers + `extension/src/wasm.d.ts` mirror
|
||||
|
||||
**Out of scope:** anything in Plan A (security/docs polish — Drop impl, recovery_qr docs, relay launcher) or Plan C (extension restructure — setup.ts SW migration, vault.ts split, StateHost typing, SW router dedup). The CLI P3 nits (let _ = entry pattern, Lock subcommand visibility, Display for ItemType, helpers::relicario_dir adoption sweep, gitea Client per-call construction, edit_and_history.rs scripted prompts, dead test variable, three-test-env-var macro, cmd_recovery_qr_unwrap empty input check, Task 12 cleanup). Server findings (P2/P3 in DEV-B's relicario-server section). WASM findings beyond the parser exports needed for P1.10 (DEV-B's WASM P2 list — double-lookup, Vec<u8> clone, naming, concurrency primitive split). The 8 "Open architectural decisions". The WASM JS-naming snake_case → camelCase decision (deferred to a separate plan). If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Phase 1 is **mechanical** — no logic changes, no signature changes, no error-message reword. Run `cargo check -p relicario-cli` between every file extraction; existing CLI integration tests at `crates/relicario-cli/tests/*` must stay green throughout. They are the regression budget.
|
||||
- Phase 2's `git_run` switches from `.status()` (inherited stderr to TTY) to `.output()` (captured); the captured stderr MUST be printed to the user's stderr unmodified on failure. Do not silently swallow.
|
||||
- Phase 5's `ParamsFile` migration is on-disk-format-sensitive. Field names and types MUST match the existing `params.json` shape exactly; the round-trip test against a fixture string is required by the plan's Done criteria.
|
||||
- Phase 7 — `MonthYear::new` currently returns `Result<_, &'static str>` (DEV-A's P3 nit). The plan recommends re-wrapping the error in `MonthYear::parse` rather than migrating `new` to `RelicarioError`. If you'd rather migrate `new` for consistency, escalate to PM first — this is a cross-plan coordination concern.
|
||||
- Phase 8 keeps **snake_case** JS names (consistent with every existing export). Do not introduce camelCase for the three new exports. The snake_case → camelCase decision is deferred to a separate plan.
|
||||
- Phase 8 updates `extension/src/wasm.d.ts` and the new `#[wasm_bindgen]` exports in the same commit. Per DEV-C's boundary note, `wasm.d.ts` is hand-maintained — do not let the two surfaces drift even temporarily.
|
||||
- The Steam alphabet at `crates/relicario-core/src/item_types/totp.rs:13` is **intentionally non-RFC-4648** and must NOT move into the new `pub(crate) mod base32`. Add a neighbour comment per the plan.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of multiple terminals. The relay routes messages between them.
|
||||
|
||||
**At every phase boundary** (complete, blocked, or question): call `read_messages(for="dev-b")` first, then post your update via `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
|
||||
Branch: feature/2026-05-04-b-cli-restructure
|
||||
Task: <phase number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-B
|
||||
Time: <iso8601>
|
||||
Context: <what phase, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-B` blocks from the PM via relay (or relayed by user if relay is down). Acknowledge and act.
|
||||
|
||||
## Cross-plan coordination
|
||||
|
||||
- **Plan C consumes your Phase 8 WASM exports** (`parse_month_year` / `base32_decode_lenient` / `guess_mime`) — but only as a deferred follow-up, NOT in Plan C's current execution. You ship the seam; Plan C does not wire the SW handlers in this train. Sequence Phase 8 to land before Plan C touches `wasm.d.ts` if both must touch it.
|
||||
- **`extension/src/wasm.d.ts` shared touchpoint with Plan C.** Plan C says it likely does NOT need to touch this file (its `create_vault`/`attach_vault` handlers reuse already-declared WASM entries). If Plan C does end up touching it, sequence Plan B's edits first and ask Plan C to rebase.
|
||||
- **Plan A's `impl Drop for SessionHandle`** is independent of you. Your `git_run` and parser-migration work doesn't touch the WASM crate's session module. No conflict.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute phase-to-phase per the plan
|
||||
- Make implementation decisions consistent with the plan and synthesis
|
||||
- Add new tests, refactor your own code, fix bugs you introduce
|
||||
- Choose between optional approaches the plan calls out (e.g. whether `Vault::after_manifest_change` calls `save_manifest` internally vs renaming the existing method to `save_manifest_raw`)
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- You discover the grep at the top of Phase 7 returns non-CLI consumers of `parse_month_year` / `base32_decode_lenient` / `guess_mime` / `base32_encode` / `decode_base32_totp` (the plan was drafted assuming zero non-CLI consumers; investigate)
|
||||
- A `cargo test --workspace` failure you can't reproduce locally
|
||||
- You want to deviate on the `MonthYear::new` consistency point (see Hard Rules)
|
||||
- You discover the `ParamsFile` round-trip is not field-compatible with the current on-disk format (rename, type change, etc.)
|
||||
- A test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- Anything destructive (per project rules)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
# From the worktree root (/home/alee/Sources/relicario-plan-b):
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets --no-deps
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
|
||||
# Done-criteria sanity greps (all should return zero matches):
|
||||
grep -n 'bail!("git ' crates/relicario-cli/src/
|
||||
grep -n 'refresh_groups_cache' crates/relicario-cli/src/ | grep -v 'session\.rs'
|
||||
# These should each return one match:
|
||||
grep -n 'struct ParamsFile' crates/relicario-cli/src/
|
||||
|
||||
# CLI smoke (post-split, end-to-end):
|
||||
cargo run -p relicario-cli -- --help
|
||||
```
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/2026-05-04-b-cli-restructure
|
||||
gh pr create --base main --head feature/2026-05-04-b-cli-restructure --title "refactor(cli): split main.rs + git_run helper + parsers→core (Plan B)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Architecture-review followup Plan B (CLI restructure — single biggest readability lift). Source: `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`.
|
||||
|
||||
- **P1.2** — `cli/main.rs` split from 2641 LOC into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr,init,generate,rate}.rs` + `prompt.rs` + `parse.rs`. `main.rs` retains clap + dispatch only (~470 lines).
|
||||
- **P1.3** — `helpers::git_run(repo, args, context)` captures stderr; 16 duplicated bail sites collapsed.
|
||||
- **P1.10** — `parse_month_year` / `base32_decode_lenient` / `guess_mime` migrated to `relicario-core` (`MonthYear::parse`, `pub(crate) mod base32::{encode_rfc4648,decode_rfc4648_lenient}`, `mime::guess_for_extension`); also closes DEV-A's three-base32-impls finding by extracting the shared module. WASM exports added; `extension/src/wasm.d.ts` mirrored.
|
||||
- **CLI P2 cluster** — `prompt_or_flag<T>` compression of `build_*_item`; `Vault::after_manifest_change` centralizes the `refresh_groups_cache` discipline (7 sites collapsed); single canonical `ParamsFile` shared between init writer and unlock reader; batched purge — a 50-item `trash empty` is now 3 git invocations instead of 150.
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] `cargo clippy --workspace` silent
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean
|
||||
- [ ] `grep -n 'bail!("git ' crates/relicario-cli/src/` returns zero matches
|
||||
- [ ] `grep -n 'refresh_groups_cache' crates/relicario-cli/src/` returns zero matches outside `session.rs`
|
||||
- [ ] `grep -n 'struct ParamsFile' crates/relicario-cli/src/` returns one match
|
||||
- [ ] New test asserts a multi-item `trash empty` produces exactly one new git commit
|
||||
- [ ] All existing CLI integration tests at `crates/relicario-cli/tests/*` still pass without modification
|
||||
|
||||
## Done criteria
|
||||
|
||||
Per `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Done criteria — every checkbox.
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL (post via `post_message`).
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created at `/home/alee/Sources/relicario-plan-b`, plan absorbed, on `feature/2026-05-04-b-cli-restructure`). Post it via `post_message(from="dev-b", to="pm", kind="status", body="...")`. Then start Phase 1 of your plan (mechanical split of `cli/main.rs`). Remember: phase 1 is mechanical — `cargo check` between every file extraction.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Dev C Kickoff Prompt — arch-followup Plan C
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan C for the arch-followup "architecture-review followups" release train.
|
||||
|
||||
Plan C is the **extension restructure** — the largest of the three (multi-day to multi-week). It eliminates the two steepest learning cliffs in the extension. After this plan ships, `setup.ts` no longer imports `relicario-wasm` directly (it isn't the pattern; it was the exception); `vault.ts` shrinks from 1027 LOC to ~200 of routing + state; `shared/state.ts` becomes type-checked end-to-end; the duplicated SW router helpers consolidate into one home each; and the extension closes its last CLI-parity gap (`relicario status` → vault-sidebar status indicator). Six phases.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-A (security & docs polish) and Dev-B (CLI restructure). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. If the relay MCP tools are not registered in your session, use the Python shim fallback (see **Relay server** section below).
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario-plan-c -b feature/2026-05-04-c-extension-restructure
|
||||
cd ../relicario-plan-c
|
||||
pwd # should print /home/alee/Sources/relicario-plan-c (or similar absolute path)
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario-plan-c`**. Force-cd subagents into this directory — the project's `CLAUDE.md` memory rule explicitly requires that subagent prompts MUST start with `cd /home/alee/Sources/relicario-plan-c` so subagents don't accidentally commit to main. This is non-negotiable.
|
||||
|
||||
Today: 2026-05-04. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-c"}'
|
||||
```
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules (Spanish flourish in chat replies only, capitalization, autonomy defaults, CLI/extension parity philosophy, security defense-in-depth)
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2s + the `relicario status` parity gap only**)
|
||||
3. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — your plan, execute phase by phase
|
||||
4. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — DEV-C's full notes (your primary source — the synthesis abbreviates)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — read **only** the "Boundary notes for DEV-C" section near the end (14 numbered contracts the JS side must respect when interacting with WASM; several inform your scope)
|
||||
|
||||
You do NOT need to read Plan A or Plan B in detail. Skim Plan A's Phase 2 (the `service-worker/session.ts:26` swallow removal) and Plan B's Phase 8 (WASM parser exports) only if a coordination question arises.
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (per project memory's default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario-plan-c
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
**Sequencing matters.** Phase 1 (typed `StateHost`) is the precondition for phases 3 and 4. Phase 2 (SW storage extraction) is independent and can ship in parallel. Phases 3 and 4 both depend on phase 1. Phase 5 (P2 cluster) and phase 6 (`get_vault_status`) are independent of 3 and 4 — they can run in parallel.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- Phase 1 — Typed `StateHost` interface in `extension/src/shared/state.ts` (no `any` in public surface) + generic `getState`/`setState` over `keyof PopupState` + double-registration guard + `__resetHostForTests` helper. Includes migration of `View` and `PopupState` from `extension/src/popup/popup.ts` to `extension/src/shared/types.ts` (or a new `shared/popup-state.ts`) to avoid a `popup → shared → popup` circular import.
|
||||
- Phase 2 — Extract `extension/src/service-worker/storage.ts` (`loadDeviceSettings`, `loadBlacklist`, `saveBlacklist` from both router files) + move `itemToManifestEntry` to `extension/src/service-worker/vault.ts`.
|
||||
- Phase 3 — Setup wizard SW migration: add `create_vault` and `attach_vault` SW messages; rewrite `setup.ts` as UI-only that posts those messages; convert the 6-step procedural wizard to a step-registry pattern; add `clearWizardState()` on `beforeunload` + step-0 reset.
|
||||
- Phase 4 — Split `vault.ts` into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`. Lift `vault_locked` RPC intercept into `shared/state.ts`. Reset `state.drawerOpen` on non-list `renderPane`. Debounce sidebar search (50-100ms).
|
||||
- Phase 5 — P2 cluster: inactivity-timer reset on content-callable messages (with documented exclusion set); `state.gitHost` clear on session expiry; teardown helper extraction (`teardownSettingsCommon`); `Promise.allSettled` in devices/trash; MutationObserver debounce in `content/detector.ts`.
|
||||
- Phase 6 — `get_vault_status` SW message + vault-sidebar status indicator (closes the `relicario status` parity gap).
|
||||
|
||||
**Out of scope:** anything in Plan A (security/docs polish — `impl Drop`, `service-worker/session.ts:26` swallow removal, `.free()` audit, `recovery_qr.rs` docs, server hardening, env-var audit) or Plan B (CLI restructure — `cli/main.rs` split, `git_run`, parser migration to core; you only **consume** the WASM exports as a deferred follow-up). Extension P3s (form-header `isInTab()` redundancy, popup.ts `isInTab()` heuristic, item-form.ts `renderComingSoon` dead code, `types/login.ts` size, `vault.ts:18-26` backup-panel comment, capture/detector/fill username-finder dedup, capture submit-button hook scope, setup.ts passphrase-score `-1` sentinel, `setup.ts:1056-1062` chrome.storage bypass, `setup.ts:1-7` "5-step" header, glyphs.ts partial adoption, `types.ts` TotpKind flat-union, `totp-tools.ts:39-46` swallowed rejections, generator-panel cleanup guard, `item-list.ts` popover listeners, popup `popup.ts:178-181` unconditional teardowns). Other parity items (per-attachment `delete_attachment` SW message, `list --tag` filter doc note). Cross-cutting items not explicitly listed (chrome.storage.local direct reads outside the setup migration, bun test runner doc note, manifest version sync). The 8 "Open architectural decisions". WASM JS-naming snake_case → camelCase (deferred to a separate plan). Anything touching the in-flight uncommitted v0.5.x work. If you trip over an out-of-scope issue or a new bug, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- **Phase 1 first.** Do NOT start phases 3 or 4 until phase 1 is green and committed. The typed `StateHost` is the contract phases 3 and 4 build against.
|
||||
- **`View` / `PopupState` migration is part of phase 1**, not phase 4. Doing it later creates circular imports that surface mid-refactor and waste a day.
|
||||
- **Do NOT redo Plan A's `.free()` swallow removal** at `extension/src/service-worker/session.ts:26`. That's Dev-A's. But wherever your refactor *moves* a `.free()` callsite — most notably during phase 3 when `setup.ts`'s `verifiedHandle` retires and the new `create_vault`/`attach_vault` SW handlers acquire their own handles — the new location MUST call `wasm.lock(handle)` first regardless of whether Plan A's Rust-side `impl Drop` has landed yet. Cite Plan A as the source of the policy in your phase 3 commit message.
|
||||
- **`extension/src/wasm.d.ts` coordination with Plan B.** Plan B Phase 8 will touch this file for new parser exports. Verify by reading `extension/src/service-worker/vault.ts` whether your `create_vault`/`attach_vault` SW handlers need new WASM entry points — they likely don't (the SW already orchestrates `unlock`/`embed_image_secret`/`register_device`/`manifest_encrypt`). If you DO need new entries, escalate via `## QUESTION TO PM` so the touch order with Plan B can be sequenced.
|
||||
- **`create_vault` and `attach_vault` SW handlers must be transactional** — they hold their own internal session reference for the duration of the operation and do NOT consult or reset the user-facing inactivity timer until they return successfully. Document this contract in the handler header comments.
|
||||
- **Phase 4 `vault_locked` channel unification** keeps both signals (the SW's `session_expired` event AND the new `shared/state.ts` wrapper's intercept) firing during the migration window. Collapse only after both surfaces are verified consuming from `shared/state.ts`. Add a regression test asserting popup lock screen renders on `session_expired` and vault tab lock screen renders on the SW response intercept.
|
||||
- **Round out the WASM stub at `extension/src/__stubs__/relicario_wasm.stub.ts`** as part of phase 3. DEV-C noted only 7 of ~25 exports are stubbed; phase 3's vitest tests for `create_vault`/`attach_vault` need stubs for `embed_image_secret`, `register_device`, `manifest_encrypt`. Add them rather than file a separate ticket.
|
||||
- The `recovery_qr_generated_at` direct chrome.storage.local write at `setup.ts:1056-1062` is **out of scope** — leave it as-is; defer to a P3 cleanup.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of multiple terminals. The relay routes messages between them.
|
||||
|
||||
**At every phase boundary** (complete, blocked, or question): call `read_messages(for="dev-c")` first, then post your update via `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")` and also print it here. Use this format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-C
|
||||
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
|
||||
Branch: feature/2026-05-04-c-extension-restructure
|
||||
Task: <phase number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task**: post via `post_message(kind="question")` with format:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-C
|
||||
Time: <iso8601>
|
||||
Context: <what phase, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-C` blocks from the PM via relay (or relayed by user if relay is down). Acknowledge and act.
|
||||
|
||||
## Cross-plan coordination
|
||||
|
||||
- **Plan A owns the `.free()` swallow removal** (`service-worker/session.ts:26`) and the Rust `impl Drop for SessionHandle`. Do not redo that work. Do honor the policy where you move callsites (see Hard Rules).
|
||||
- **Plan B Phase 8 ships WASM parser exports** (`parse_month_year` / `base32_decode_lenient` / `guess_mime`) that the extension can eventually consume. Consumption (SW message handlers wrapping the new exports) is **explicitly deferred to a future plan** — do NOT design those handlers in this train. The seam exists; nobody is wiring it yet.
|
||||
- **`extension/src/wasm.d.ts` shared touchpoint with Plan B.** See Hard Rules — you likely don't need to touch it. If you do, escalate to PM for sequencing.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute phase-to-phase per the plan
|
||||
- Make implementation decisions consistent with the plan and synthesis
|
||||
- Choose how the typed `StateHost` exposes `setState` (the plan suggests `setState<K extends keyof PopupState>`; pick the variant that gives the cleanest call-site ergonomics)
|
||||
- Pick which file `View` and `PopupState` migrate to (`shared/types.ts` vs new `shared/popup-state.ts`) — the plan accepts either
|
||||
- Add new tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- You discover phase 3's setup-to-SW migration needs new WASM entry points (would touch `wasm.d.ts` and conflict with Plan B Phase 8)
|
||||
- You discover the `view`/`PopupState` migration in phase 1 surfaces more TS errors than the plan estimated (~15-30); if you hit 100+ errors, the surface area is bigger than the plan accounts for
|
||||
- You discover a real bug in the existing `vault_locked` channels (e.g. popup currently doesn't actually receive `session_expired` despite the plan's premise)
|
||||
- A vitest test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- Anything destructive (per project rules)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
# From the worktree root (/home/alee/Sources/relicario-plan-c):
|
||||
cd extension
|
||||
npm test # vitest
|
||||
npm run build # Chrome build
|
||||
npm run build:firefox # Firefox build
|
||||
cd ..
|
||||
|
||||
# Done-criteria sanity greps:
|
||||
grep -n ': any' extension/src/shared/state.ts # should return zero
|
||||
grep -rn 'relicario-wasm' extension/src/setup/ # should return zero (post-Phase-3)
|
||||
wc -l extension/src/setup/setup.ts # should be ≤ ~500
|
||||
wc -l extension/src/vault/vault.ts # should be ~200
|
||||
grep -n 'loadDeviceSettings\|loadBlacklist\|saveBlacklist' \
|
||||
extension/src/service-worker/router/popup-only.ts \
|
||||
extension/src/service-worker/router/content-callable.ts # should be imports only, no defs
|
||||
grep -n 'itemToManifestEntry' extension/src/service-worker/router/ # should be imports only
|
||||
```
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/2026-05-04-c-extension-restructure
|
||||
gh pr create --base main --head feature/2026-05-04-c-extension-restructure --title "refactor(ext): typed StateHost + setup→SW + vault.ts split (Plan C)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Architecture-review followup Plan C (extension restructure — eliminates the two steepest learning cliffs). Source: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`.
|
||||
|
||||
- **P1.6** — `extension/src/shared/state.ts` now has a concrete `StateHost` interface (no `any` in public surface), `getState`/`setState` generic over `keyof PopupState`, double-registration guard, `__resetHostForTests` helper. `View` and `PopupState` migrated from `popup/popup.ts` to `shared/types.ts` to break circular import.
|
||||
- **P1.9** — `service-worker/storage.ts` extracted; `itemToManifestEntry` moved to `service-worker/vault.ts`. Both router files import; no more duplicated definitions.
|
||||
- **P1.4** — `setup.ts` no longer imports `relicario-wasm`. New `create_vault` / `attach_vault` SW messages handle vault creation transactionally. Procedural wizard converted to a step-registry pattern (`{ id, render, attach }[]`). `clearWizardState()` on `beforeunload` + step-0 reset wipes sensitive `Uint8Array` material best-effort. `setup.ts` LOC dropped from 1220 to ~500.
|
||||
- **P1.5** — `vault.ts` split into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`. `vault.ts` retained at ~200 LOC (routing + state only). `vault_locked` RPC intercept lifted into `shared/state.ts` so popup and vault tab consume one channel. Drawer auto-closes on non-list views. Sidebar search debounced.
|
||||
- **P2 cluster** — inactivity timer resets on all messages except a documented exclusion set; `state.gitHost` clears on session expiry; `teardownSettingsCommon` extracted; `devices.ts`/`trash.ts` use `Promise.allSettled`; `content/detector.ts` MutationObserver debounced.
|
||||
- **`get_vault_status`** — closes the `relicario status` parity gap. New SW message returns cached `{ ahead, behind, lastSyncAt, pendingItems }`; vault sidebar renders an indicator on mount + manual refresh.
|
||||
|
||||
## Cross-plan coordination respected
|
||||
|
||||
- **Plan A** owns the `service-worker/session.ts:26` swallow removal and the Rust `impl Drop`. This PR does NOT redo that work. Wherever this refactor moved a `.free()` callsite (Phase 3 setup-to-SW migration), the new location calls `wasm.lock(handle)` first regardless of Plan A's status.
|
||||
- **Plan B Phase 8** WASM parser exports are a seam this PR does NOT consume in this train. Future plan wires the SW handlers.
|
||||
- **`extension/src/wasm.d.ts`** not touched by this PR (verified at Phase 3).
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] `cd extension && npm test` passes (vitest including new tests for typed state, SW storage helpers, `clearWizardState`, drawer auto-close, `vault_locked` channel, `get_vault_status`)
|
||||
- [ ] `cd extension && npm run build && npm run build:firefox` clean
|
||||
- [ ] `grep -n ': any' extension/src/shared/state.ts` returns zero
|
||||
- [ ] `grep -rn 'relicario-wasm' extension/src/setup/` returns zero
|
||||
- [ ] `wc -l extension/src/setup/setup.ts` ≤ ~500
|
||||
- [ ] `wc -l extension/src/vault/vault.ts` ~200
|
||||
- [ ] Manual smoke: load extension → setup → unlock → vault tab → drawer behavior → settings → trash
|
||||
- [ ] Manual smoke: trigger session expiry, confirm both popup and vault tab show lock screen
|
||||
- [ ] Manual smoke: vault sidebar status indicator updates on sync
|
||||
|
||||
## Done criteria
|
||||
|
||||
Per `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` Done criteria — every checkbox.
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL (post via `post_message`).
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created at `/home/alee/Sources/relicario-plan-c`, plan absorbed, on `feature/2026-05-04-c-extension-restructure`). Post it via `post_message(from="dev-c", to="pm", kind="status", body="...")`. Then start Phase 1 of your plan (typed `StateHost` + `View`/`PopupState` migration). Remember: phase 1 is the precondition for phases 3 and 4 — do not start them until phase 1 is green.
|
||||
@@ -0,0 +1,162 @@
|
||||
# PM Kickoff Prompt — arch-followup architecture-review followups
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the arch-followup "architecture-review followups" release train. 3 senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all 4 terminals; a relay MCP server routes messages between you so the user does not need to copy-paste.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
```
|
||||
|
||||
The shim connects over HTTP and has the same semantics as the MCP tools.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules (Spanish flourish in chat replies only, capitalization, autonomy defaults, never run git-destructive commands without asking)
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — the canonical synthesis that drove all three plans (10 P1s + P2/P3 tail + 8 open architectural decisions)
|
||||
3. `docs/superpowers/specs/2026-05-04-security-polish-design.md` — Plan A: security & docs polish (S, independent, ships first)
|
||||
4. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B: CLI restructure (M-L, multi-day)
|
||||
5. `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — Plan C: extension restructure (L, multi-day to multi-week, largest)
|
||||
6. `docs/superpowers/coordination/RELAY.md` — multi-agent paradigm + relay reference (you are inside this paradigm right now)
|
||||
|
||||
Skim the per-reviewer notes only if a dev's question forces it: `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` (Rust core), `dev-b-notes.md` (Rust consumers), `dev-c-notes.md` (TypeScript).
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from each dev's feature branch
|
||||
- Drive any release-prep work that isn't a feature plan (e.g. CHANGELOG entries per merged PR, version bumps if any) — this is your hands-on work
|
||||
- Coordinate sequencing of the cross-plan touch points (see "Cross-plan coordination" below)
|
||||
- Tag a milestone (e.g. `arch-followup-complete` or whatever the user prefers) once everything is integrated **— but only after explicit user approval**
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` are fine.
|
||||
- Don't deviate from the spec without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag without user approval.
|
||||
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`).
|
||||
|
||||
## Cross-plan coordination (you must enforce)
|
||||
|
||||
The three plans were drafted with explicit cross-boundary cites. Hold devs to them:
|
||||
|
||||
- **Plan A is fully independent of B and C.** It can ship anytime. There is no merge-order dependency.
|
||||
- **Plan B Phase 8 (WASM parser exports + `wasm.d.ts` mirror) is a seam Plan C will eventually consume**, but the consumption (SW message handlers wrapping the new exports) is **explicitly deferred to a future plan**. B does NOT block C, and C does NOT block B during execution. The seam exists; nobody is wiring it yet.
|
||||
- **Both B and C touch `extension/src/wasm.d.ts` at most once each.** If both must touch it in the same window, sequence Plan B's edits first and rebase Plan C on top. Plan C's design says it likely does NOT need to touch `wasm.d.ts` (its `create_vault`/`attach_vault` SW handlers reuse already-declared WASM entries) — verify when C reaches Phase 3.
|
||||
- **Plan A owns the removal of the `try { current.free() }` swallow** at `extension/src/service-worker/session.ts:26` and the Rust-side `impl Drop for SessionHandle`. Plan C must NOT redo that work, but wherever Plan C *moves* a `.free()` callsite (most notably during the Phase 3 setup-to-SW migration where `setup.ts`'s `verifiedHandle` retires and the new `create_vault`/`attach_vault` handlers acquire their own handles), the new location must call `wasm.lock(handle)` first regardless of Plan A's status.
|
||||
|
||||
## Judgment calls in the plans worth flagging
|
||||
|
||||
The subagents who drafted the plans flagged these decisions for your awareness:
|
||||
|
||||
- **Plan A Phase 4 — `tools/relay/queue.test.ts:54` is already fixed** in commit `061facd` (planning subagent verified). The phase records this as a verification step rather than a code change. Plan A's real Phase 4 work is `tools/relay/start.sh` — both `launch_tmux` and `launch_kitty` still launch only PM/Dev-A/Dev-B (no Dev-C window); the manual-mode banner still says "Open 3 new terminals"; no `*-dev-c-prompt.md` is discovered. That part IS still needed.
|
||||
- **Plan A `.free()` audit yielded exactly one callsite** under `extension/src/` (the SW one at `session.ts:26`). The plan records the grep command for reproducibility; if a future surface adds a second callsite, this PR's grep becomes the baseline.
|
||||
- **Plan B Phase 7 — `MonthYear::new` currently returns `Result<_, &'static str>`** (DEV-A's P3 nit). Plan B recommends re-wrapping the error in `MonthYear::parse` rather than migrating `new` to `RelicarioError` — keeps Plan B focused. If you'd rather have Plan B address the consistency now, raise it with the user before Dev-B starts Phase 7.
|
||||
- **Plan B Phase 8 keeps snake_case naming** for the new WASM exports (consistent with every existing export). The snake_case → camelCase decision (DEV-B/DEV-C P3) is **explicitly deferred to a separate plan**; introducing camelCase only for the three new exports would create a worst-of-both-worlds inconsistency.
|
||||
- **Plan C Phase 1 — `View` and `PopupState` currently live in `extension/src/popup/popup.ts`** (lines 70-92). The phase 1 typed `StateHost` interface needs to import them, but doing so directly creates a `popup → shared → popup` circular import. The plan calls for migrating those types to `extension/src/shared/types.ts` (or a new `shared/popup-state.ts`) as part of phase 1 before re-importing. Make sure Dev-C has done this migration before consuming TS errors elsewhere.
|
||||
- **Plan C noted only 7 of ~25 WASM exports** are currently stubbed in `extension/src/__stubs__/relicario_wasm.stub.ts`. Plan C Phase 3's vitest tests for `create_vault`/`attach_vault` will need additional stubs (`embed_image_secret`, `register_device`, `manifest_encrypt`) — Dev-C should round those out as part of the phase rather than file a separate ticket.
|
||||
|
||||
If any of these conflict with your judgment, raise it with the user before kickoff.
|
||||
|
||||
## The 8 "Open architectural decisions"
|
||||
|
||||
The synthesis appendix lists 8 decisions that are user-judgement calls, not implementation tasks:
|
||||
|
||||
1. Was `impl Drop for SessionHandle` deliberately omitted? — **Plan A confirms it was not**, and ships the fix.
|
||||
2. Should CLI parsers migrate to core? — **Plan B Phase 7 says yes** and ships the migration.
|
||||
3. Bootstrap rule for missing `devices.json` — **out of scope**, defer.
|
||||
4. `Lock` no-op CLI subcommand visibility — **out of scope**, defer.
|
||||
5. Task 12 cleanup status (`cmd_backup_export` still reads `devices.json`) — **out of scope**, defer.
|
||||
6. `tools/relay/call.py` and `call.ts` tracking — **already tracked** (per `RELAY.md`).
|
||||
7. WASM JS naming snake_case → camelCase — **explicitly deferred** to a separate plan (Plan B Phase 8 does NOT take a position).
|
||||
8. `.gitea_env_vars` gitignore — **already done** in commit `4a726c2`.
|
||||
|
||||
You do not need to act on any of these unless the user reopens one. They're listed so you have the context if a dev surfaces a related question.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of 4 terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action. If the relay MCP tools are not registered in your session, fall back to the Python shim (see **Relay server** section above) or ask the user to relay manually.
|
||||
|
||||
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks, either from the relay inbox or relayed by the user if the relay is down.
|
||||
|
||||
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-<letter>
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user at any time, give a current rollup:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — arch-followup
|
||||
Devs: <per-dev one-line state>
|
||||
PM: <what you're working on>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Dev A REVIEW-READY", "Plan A merged">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check the diff against the spec (synthesis) and the plan's Done criteria checklist
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash — the project preserves git as audit log per `CLAUDE.md`)
|
||||
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
|
||||
|
||||
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving. For Plan A in particular (defense-in-depth crypto fix), an independent review is worth the cost.
|
||||
|
||||
## Per-plan acceptance gating
|
||||
|
||||
- **Plan A:** every Done-criteria checkbox in `2026-05-04-security-polish-design.md` checked off; the `.free()` audit grep recorded in PR description; both the wasm-bindgen-test and the native `#[test]` for `Drop` present and passing; `recovery_qr.rs` documentation density visibly matches `crypto.rs` / `imgsecret.rs`.
|
||||
- **Plan B:** every Done-criteria checkbox in `2026-05-04-cli-restructure-design.md` checked off; `cargo test --workspace` green; `cargo clippy --workspace` silent; `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean; the 16 `bail!("git X failed")` sites all collapsed; the 7 `refresh_groups_cache` callsites all using `after_manifest_change`; one canonical `ParamsFile`; batched purge measured (3 git invocations for ≥3 items).
|
||||
- **Plan C:** every Done-criteria checkbox in `2026-05-04-extension-restructure-design.md` checked off; vitest green; `bun run build` + `bun run build:firefox` clean; no `any` in `StateHost`; SW router files import the new helpers; `setup.ts` ≤ ~500 LOC and does not import `relicario-wasm`; `vault.ts` ~200 LOC of routing+state only; single `vault_locked` channel.
|
||||
|
||||
## Pre-tag checklist
|
||||
|
||||
Before tagging or otherwise marking the followup train complete:
|
||||
|
||||
- [ ] All three plan branches merged to main
|
||||
- [ ] Full test suite green on main: `cargo test --workspace && cd extension && npm test && cd ../tools/relay && bun test`
|
||||
- [ ] Standard build green on main: `cargo build && cd extension && npm run build && npm run build:firefox`
|
||||
- [ ] User-driven smoke test of the merged result (load extension, exercise unlock + setup + a vault op; run the CLI through `relicario init`/`add`/`list`/`sync`)
|
||||
- [ ] Synthesis P2/P3 tail re-evaluated — anything still worth a follow-up plan? If so, draft a one-line tracker.
|
||||
- [ ] Explicit user approval to mark the train complete
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the synthesis, all three plans, and the cross-plan coordination notes. Note the judgment calls above for the user's awareness.
|
||||
3. Send opening directives to all three devs via `post_message`:
|
||||
- **Dev-A:** start Plan A Phase 1 (Rust `impl Drop` + test). It's S-effort and independent — no waiting on anyone.
|
||||
- **Dev-B:** start Plan B Phase 1 (mechanical split of `cli/main.rs`). Cite that this is the precondition for everything else in B.
|
||||
- **Dev-C:** start Plan C Phase 1 (typed `StateHost` + `View`/`PopupState` migration). Cite that phase 1 is the precondition for phases 3 and 4. Also cite the migration of `View`/`PopupState` to `shared/types.ts` to avoid the circular import.
|
||||
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing them to proceed deeper into their plans.
|
||||
@@ -0,0 +1,64 @@
|
||||
# CLI Tail — Cycle 2 Coordinator
|
||||
|
||||
**Date:** 2026-05-09
|
||||
**Status:** Draft (launches once cycle-1 prerequisites land)
|
||||
**Theme:** parallelize the post-split tail of Plan B (the CLI restructure) across three independent streams. Plan B's eight phases are already defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`; this coordinator only partitions the remaining phases across cycle-2 streams and records the cross-stream contracts.
|
||||
|
||||
## What this is
|
||||
|
||||
The cycle-1 four-agent run (`2026-05-04-arch-followup-*`) ships:
|
||||
|
||||
- **Stream A** — Plan A (security + docs polish): `impl Drop for SessionHandle`, JS swallow removal, `recovery_qr.rs` docs, `start.sh` fourth-window. Independent of B and C.
|
||||
- **Stream B** — Plan B Phases 1 + 2 only (mechanical `main.rs` split + `helpers::git_run` + 16-site sweep). Stops after Phase 2 per a 2026-05-09 user-driven RESCOPE directive.
|
||||
- **Stream C** — Plan C (extension restructure). Did not launch in cycle 1 (DEV-C never acked); remains pending and is *not* picked up by cycle 2 (still its own multi-week effort, separate kickoff).
|
||||
|
||||
The remaining six Plan B phases (3 through 8) are partitioned across three cycle-2 streams below. Each cycle-2 stream is independent of the other two once cycle-1 Stream B (Phase 1 + 2) has merged to `main`.
|
||||
|
||||
## Pre-launch checklist (cycle 2 cannot open until all green)
|
||||
|
||||
- [ ] Cycle-1 Stream A merged to `main`
|
||||
- [ ] Cycle-1 Stream B PR (Phase 1 + 2 bundle) merged to `main`
|
||||
- [ ] Working tree clean on `main`; `git pull` reflects both merges
|
||||
- [ ] All cycle-1 worktrees torn down (`git worktree remove ../relicario.arch-followup-stream-a` and `*-stream-b`); cycle-1 branches deleted locally if requested
|
||||
- [ ] Relay server still running on `localhost:7331` (check `ss -ltn 'sport = :7331'`)
|
||||
- [ ] Cycle-2 kickoff prompts present in `docs/superpowers/coordination/2026-05-09-cli-tail-{pm,dev-a,dev-b,dev-c}-prompt.md`
|
||||
|
||||
## Stream partition
|
||||
|
||||
| Stream | Branch | Worktree | Plan B phases | Theme |
|
||||
|---|---|---|---|---|
|
||||
| A | `feature/cli-tail-stream-a-prompt-helpers` | `/home/alee/Sources/relicario.cli-tail-stream-a` | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
|
||||
| B | `feature/cli-tail-stream-b-session-manifest` | `/home/alee/Sources/relicario.cli-tail-stream-b` | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
|
||||
| C | `feature/cli-tail-stream-c-core-wasm-seam` | `/home/alee/Sources/relicario.cli-tail-stream-c` | Phases 7, 8 | parser migration to `relicario-core` + base32 dedup + WASM exports |
|
||||
|
||||
Phases reference the canonical definitions in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`. Devs do NOT redesign — they execute against that spec.
|
||||
|
||||
## Cross-stream dependencies (cycle 2)
|
||||
|
||||
- **Stream A and Stream B**: both touch `crates/relicario-cli/src/commands/*.rs` files but in disjoint ways. Stream A modifies `commands/add.rs` (the seven `build_*_item` builders). Stream B modifies `commands/init.rs` (`ParamsFile`), `commands/trash.rs` (batched purge), and seven manifest-mutation sites scattered across `commands/{add,edit,trash,attach,settings,import}.rs`. Conflict surface is `commands/add.rs` (A modifies builders; B modifies the `after_manifest_change` callsite). Whoever opens their PR second rebases.
|
||||
- **Stream B internal sequencing**: Phase 6 (batched purge) depends on Phase 4 (`after_manifest_change` wrapper) — Phase 6's commit message logic uses the wrapper. Phase 5 (`ParamsFile`) is independent of 4 and 6 within Stream B; can ship first, last, or middle.
|
||||
- **Stream C**: touches `crates/relicario-core/`, `crates/relicario-wasm/`, and `extension/src/wasm.d.ts` only. Zero overlap with Streams A and B. Internal sequencing: Phase 7 (parser migration to core) before Phase 8 (WASM exports + `wasm.d.ts` mirror).
|
||||
- **No cross-stream interface contracts.** All three plans were finalized in cycle 1; the partition does not introduce new contracts.
|
||||
|
||||
## Pre-merge checklist (per cycle-2 stream)
|
||||
|
||||
Same as cycle 1, plus a narration check:
|
||||
|
||||
- [ ] Stream's owned phases all complete per Plan B's "Done criteria"
|
||||
- [ ] `cargo test --workspace` green on the stream's worktree
|
||||
- [ ] `cargo clippy --workspace` silent
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, but Stream C in particular)
|
||||
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` tests pass without modification
|
||||
- [ ] Narration discipline observed — STATUS UPDATEs include in-flight beats, not just phase boundaries
|
||||
- [ ] PR description cross-references the corresponding Plan B phase numbers
|
||||
|
||||
## Out of scope for cycle 2
|
||||
|
||||
- Plan C (extension restructure) — multi-week effort, scheduled separately when DEV-C bandwidth available
|
||||
- The Plan B `helpers::git_run` itself (shipped in cycle 1 Stream B)
|
||||
- The cycle-1 P3 nits explicitly out-of-scope in Plan B
|
||||
- The eight "Open architectural decisions" from the synthesis
|
||||
|
||||
## Tag
|
||||
|
||||
No release tag for cycle 2. Same as the cycle-1 architecture-review followup train — these are structural-cleanup bundles, not versioned releases. Each stream merges via `gh pr merge --merge` (preserve history; no squash per project convention).
|
||||
@@ -0,0 +1,199 @@
|
||||
# Dev A Kickoff Prompt — CLI Tail (Cycle 2) Stream A
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream A of the CLI-tail cycle-2 release.
|
||||
|
||||
Stream A is **Plan B Phase 3** — `prompt_or_flag<T>` helper plus the seven `build_*_item` builder compression in the CLI. Single phase, S-M effort. The phase is defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression". Cycle 1 already shipped the mechanical `main.rs` split (Phase 1) and the `helpers::git_run` sweep (Phase 2), so the file tree under `crates/relicario-cli/src/commands/` and `prompt.rs` is in place — your job is to add the helper to `prompt.rs` and refactor the seven builders in `commands/add.rs`.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-B (session/manifest discipline — Phases 4, 5, 6) and Dev-C (parser migration + WASM seam — Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.cli-tail-stream-a -b feature/cli-tail-stream-a-prompt-helpers
|
||||
cd ../relicario.cli-tail-stream-a
|
||||
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-a
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-a`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-a` so subagents don't accidentally commit to main. This is non-negotiable.
|
||||
|
||||
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-a"}'
|
||||
```
|
||||
|
||||
**Cycle-1 lessons baked in (read once):**
|
||||
|
||||
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phase 3 only
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (your scope is **Phase 3 only**; read the whole plan for context, but execute Phase 3)
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the `build_*_item` discussion (line-level context for the seven builders the synthesis abbreviates)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (per `CLAUDE.md` memory default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per sub-step, two-stage review.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-a
|
||||
```
|
||||
|
||||
…before any other instruction. Non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Plan B Phase 3 — adding `prompt_or_flag<T>` (and `prompt_or_flag_optional<T>`) to `crates/relicario-cli/src/prompt.rs`, then refactoring the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper. Per-type bodies should shrink by ~30%.
|
||||
|
||||
**Out of scope:**
|
||||
- Phases 4, 5, 6 (Dev-B owns) — `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge
|
||||
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
|
||||
- Anything outside Plan B's Phase 3 definition. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Do not change the CLI's external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The user runs all four; the PM in another terminal coordinates you.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||
|
||||
- When you dispatch a subagent (so the user sees what's running)
|
||||
- When a subagent returns with a decision worth flagging (an unexpected finding, a trade-off taken, a surprise)
|
||||
- When a sub-task completes (e.g. `prompt_or_flag` helper landed; first builder converted)
|
||||
- When you change direction or hit something unexpected
|
||||
- When you start a new sub-step
|
||||
|
||||
The `Notes` field should narrate WHAT happened and WHY — not just "Phase 3 done". Three sentences max. Examples of useful: "subagent reported `build_login_item` already takes Result-wrapped fields, so the conversion is just chain-flattening"; "found one builder uses `prompt_secret`, kept it on raw `prompt_secret` since `prompt_or_flag` doesn't handle the no-echo case." Examples of NOT useful: "builder converted" with no context; "tests pass" with no count.
|
||||
|
||||
Print every STATUS UPDATE locally before/after sending it so the user reads it in your own terminal.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print here. Format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601>
|
||||
Branch: feature/cli-tail-stream-a-prompt-helpers
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <WHAT and WHY — 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task**: post via `post_message(kind="question")`:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-A
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM.
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within Phase 3
|
||||
|
||||
You don't need PM permission to:
|
||||
|
||||
- Execute sub-steps per Plan B's Phase 3
|
||||
- Make implementation decisions consistent with Plan B
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate when:
|
||||
|
||||
- A scope question outside Plan B Phase 3
|
||||
- A test you can't make green after honest debugging
|
||||
- A discovered bug not in Plan B
|
||||
- Anything destructive (per `CLAUDE.md`)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-a
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
All three must be green / clean. Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/cli-tail-stream-a-prompt-helpers
|
||||
gh pr create --base main --head feature/cli-tail-stream-a-prompt-helpers --title "refactor(cli): prompt_or_flag helper + build_*_item compression (Plan B Phase 3)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Adds `prompt_or_flag<T>` and `prompt_or_flag_optional<T>` to `crates/relicario-cli/src/prompt.rs`
|
||||
- Refactors the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper
|
||||
- Per-type bodies shrink by ~30%; existing CLI integration tests pass without modification
|
||||
|
||||
## Plan B Phase 3
|
||||
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phase 3.
|
||||
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||
|
||||
## Test plan
|
||||
- [x] cargo test --workspace
|
||||
- [x] cargo clippy --workspace
|
||||
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-a-prompt-helpers`), then start Phase 3 sub-step 1 (add `prompt_or_flag<T>` to `prompt.rs`).
|
||||
@@ -0,0 +1,210 @@
|
||||
# Dev B Kickoff Prompt — CLI Tail (Cycle 2) Stream B
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream B of the CLI-tail cycle-2 release.
|
||||
|
||||
Stream B is **Plan B Phases 4, 5, 6** — session/manifest discipline. Three phases, S-M effort each, total mid-day to multi-day:
|
||||
|
||||
- **Phase 4** — `Vault::after_manifest_change(&self, manifest: &Manifest)` wrapper that funnels the seven manifest-mutation sites in `commands/{add,edit,trash,attach,settings,import}.rs` through one `save_manifest + groups-cache write` path. Marks `save_manifest` as `pub(crate)` (or renames it `save_manifest_raw`) so callers must use the wrapper.
|
||||
- **Phase 5** — Single canonical `ParamsFile` in `crates/relicario-cli/src/session.rs`, replacing the two-definition split between `commands/init.rs` (write side) and `session.rs:114` (read side). Adds `Serialize` + `Deserialize`, `for_new_vault` constructor, `into_kdf_params` inversion. On-disk JSON format must round-trip with current `params.json` files.
|
||||
- **Phase 6** — Batched purge in `cmd_purge` and `cmd_trash_empty`. Renames `purge_item` to `purge_item_filesystem` (filesystem mutation only); the callers accumulate paths and run a single `git_run(...["rm", "-rf", "--ignore-unmatch", paths...])` plus `git_run(...["add", "manifest.enc"])` plus one `git_run(...["commit"])` per batch. A 50-item `trash empty` should fire 3 git invocations total, not 150.
|
||||
|
||||
The phases are defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 4", "Phase 5", "Phase 6". Internal sequencing: Phase 4 before Phase 6 (Phase 6 uses `after_manifest_change`); Phase 5 is independent of 4 and 6.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-C (Plan B Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.cli-tail-stream-b -b feature/cli-tail-stream-b-session-manifest
|
||||
cd ../relicario.cli-tail-stream-b
|
||||
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-b
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-b`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-b` so subagents don't accidentally commit to main. Non-negotiable.
|
||||
|
||||
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-b"}'
|
||||
```
|
||||
|
||||
**Cycle-1 lessons baked in (read once):**
|
||||
|
||||
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 4, 5, 6 only
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 4, 5, 6)
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes; the relevant sections are `refresh_groups_cache` discipline, `ParamsFile` dedup, batched purge
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-b
|
||||
```
|
||||
|
||||
…before any other instruction.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Plan B Phases 4, 5, 6.
|
||||
|
||||
**Out of scope:**
|
||||
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + `build_*_item` compression
|
||||
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
|
||||
- Anything outside Plan B Phases 4-6. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Phase 5 must round-trip with existing on-disk `params.json` — write a fixture-string test that reads a known-current params.json and asserts the canonical struct parses it identically. On-disk format change would break existing vaults.
|
||||
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||
- The `groups.cache` plaintext "failures silently swallowed" doc-comment from current `helpers.rs:90-93` must be preserved on the new `after_manifest_change` wrapper. Don't change the policy.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
**Internal phase sequencing (within Stream B):**
|
||||
- Phase 5 (`ParamsFile`) is independent — ship first to get it out of the way, OR last for diff-locality with the session-touching Phase 4. Either is fine; pick whichever reviews more cleanly.
|
||||
- Phase 4 (`after_manifest_change`) before Phase 6 (`batched purge`). Phase 6's commit logic relies on the wrapper.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The PM coordinates you with Dev-A and Dev-C.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||
|
||||
- When you dispatch a subagent
|
||||
- When a subagent returns with a decision worth flagging (a found-but-unexpected coupling, a trade-off taken)
|
||||
- When a sub-task completes (e.g. `after_manifest_change` wrapper landed; first manifest-mutation site converted; `ParamsFile` round-trip test green)
|
||||
- When you change direction or hit something unexpected
|
||||
- When you start a new phase
|
||||
|
||||
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "Phase 5 fixture test caught that `format_version` was previously emitted but never read; preserved the field but kept the read side tolerant"; "found one manifest-mutation site in `commands/import.rs` that did NOT call `refresh_groups_cache` historically (DEV-B notes flagged 7 sites; this is an 8th — surfacing as a question)." Print every STATUS UPDATE locally too.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")`, then post and print using:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601>
|
||||
Branch: feature/cli-tail-stream-b-session-manifest
|
||||
Task: <phase number / sub-step>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <WHAT and WHY — 3 sentences max>
|
||||
```
|
||||
|
||||
**For PM input mid-task**:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-B
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within Phases 4-6
|
||||
|
||||
You don't need PM permission to:
|
||||
|
||||
- Execute sub-steps per Plan B's Phases 4, 5, 6
|
||||
- Make implementation decisions consistent with Plan B
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate when:
|
||||
|
||||
- A scope question outside Plan B Phases 4-6
|
||||
- A test you can't make green after honest debugging
|
||||
- A discovered bug not in Plan B
|
||||
- Anything destructive (per `CLAUDE.md`)
|
||||
- Before opening the PR for review
|
||||
- If you find an unexpected manifest-mutation site beyond the seven DEV-B notes flagged (likely surfaces in Phase 4)
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-b
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
All three must be green / clean. Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/cli-tail-stream-b-session-manifest
|
||||
gh pr create --base main --head feature/cli-tail-stream-b-session-manifest --title "refactor(cli): session/manifest discipline (Plan B Phases 4, 5, 6)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Phase 4 — `Vault::after_manifest_change` wrapper funnels seven manifest-mutation sites; `save_manifest` made `pub(crate)` so callers can't bypass the wrapper
|
||||
- Phase 5 — Single canonical `ParamsFile` in `session.rs` replaces the two-definition split; on-disk JSON round-trips with existing vaults (fixture-string test)
|
||||
- Phase 6 — Batched purge: a 50-item `trash empty` now fires 3 git invocations instead of 150
|
||||
|
||||
## Plan B Phases 4-6
|
||||
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 4, 5, 6.
|
||||
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||
|
||||
## Test plan
|
||||
- [x] cargo test --workspace
|
||||
- [x] cargo clippy --workspace
|
||||
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
- [x] params.json round-trip test against existing on-disk format
|
||||
- [x] `trash empty` with N items produces 1 commit (regression invariant)
|
||||
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-b-session-manifest`), then start Phase 4 (or Phase 5 if you prefer to ship the independent piece first — call it out in the status update).
|
||||
@@ -0,0 +1,219 @@
|
||||
# Dev C Kickoff Prompt — CLI Tail (Cycle 2) Stream C
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream C of the CLI-tail cycle-2 release.
|
||||
|
||||
Stream C is **Plan B Phases 7 and 8** — the parser migration to `relicario-core` plus the WASM seam. Two phases, M effort:
|
||||
|
||||
- **Phase 7** — Migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` from `crates/relicario-cli/src/parse.rs` into `relicario-core` (`MonthYear::parse` on `time.rs`, new `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648_lenient`, new `mime::guess_for_extension`). Pair with DEV-A's P2 base32 dedup: extract the inline `base32_encode` from `crates/relicario-core/src/item.rs:255-275` and `decode_base32_totp` from `crates/relicario-core/src/import_lastpass.rs:202-220` into the new shared module. Steam's `STEAM_ALPHABET` at `item_types/totp.rs:13` stays untouched (with a neighbour comment). The CLI's `parse.rs` becomes a thin re-export shim — no callsite changes in cycle 2.
|
||||
- **Phase 8** — `#[wasm_bindgen]` exports for the three migrated parsers (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) plus the matching declarations in `extension/src/wasm.d.ts`. snake_case JS naming consistent with every existing export. Plan C (extension restructure) does NOT consume these this round — the seam ships in cycle 2; consumption is a future plan.
|
||||
|
||||
Phase definitions are canonical in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8. Internal sequencing: Phase 7 before Phase 8.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-B (Plan B Phases 4, 5, 6). With the relay server running, you communicate via `post_message` / `read_messages` directly.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.cli-tail-stream-c -b feature/cli-tail-stream-c-core-wasm-seam
|
||||
cd ../relicario.cli-tail-stream-c
|
||||
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-c`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-c` so subagents don't accidentally commit to main. Non-negotiable.
|
||||
|
||||
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-c"}'
|
||||
```
|
||||
|
||||
**Cycle-1 lessons baked in (read once):**
|
||||
|
||||
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 DEV-A and DEV-B both hit this; documenting once here so cycle-2 DEV-C does not.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 7 + 8 only
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 7 and 8)
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — DEV-A's notes; the relevant section is the P2 "three base32 implementations" finding (the dedup that pairs with your Phase 7)
|
||||
6. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the parser-migration P2 (line-level context for `parse_month_year`, `base32_decode_lenient`, `guess_mime`)
|
||||
7. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read **only** the "Boundary notes for DEV-B" section (cross-boundary contracts — `wasm.d.ts` is hand-maintained; every change must mirror; BigInt typing care for `attachment_encrypt`-style paths, but your three new exports take only `&str` and return primitives so they avoid that class)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
```
|
||||
|
||||
…before any other instruction.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Plan B Phases 7 and 8 — parser migration to `relicario-core` (paired with DEV-A P2 base32 dedup), then WASM exports + `extension/src/wasm.d.ts` mirror.
|
||||
|
||||
**Out of scope:**
|
||||
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + builder compression
|
||||
- Phases 4, 5, 6 (Dev-B owns) — session/manifest discipline
|
||||
- Plan C (extension restructure) — consumption of your new WASM exports is explicitly deferred to a future plan; you ship the seam, you do NOT wire SW message handlers in the extension.
|
||||
- Anything outside Plan B Phases 7-8. If you trip over an out-of-scope issue (e.g. a fourth base32 implementation surfaces; a parser the CLI uses that wasn't in Plan B's three), file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Steam's `STEAM_ALPHABET` at `crates/relicario-core/src/item_types/totp.rs:13` is intentionally non-RFC-4648; do NOT consolidate it into the new shared base32 module. Add a neighbour comment: `// not RFC 4648 — Steam Guard's de-ambiguated alphabet; see crate::base32 for the standard impl.`
|
||||
- The CLI's `parse.rs` becomes a thin re-export shim — keep callsite imports unchanged in cycle 2 (no caller-side import churn).
|
||||
- WASM JS naming stays snake_case for the three new exports — consistent with every existing `#[wasm_bindgen]` export. Do NOT introduce camelCase here; that decision is explicitly deferred per Plan B.
|
||||
- `extension/src/wasm.d.ts` mirror lands in the same commit as the Rust `#[wasm_bindgen]` additions. Both sides updated together; no half-state.
|
||||
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
**Internal phase sequencing (within Stream C):**
|
||||
- Phase 7 (parser migration to core + base32 dedup) before Phase 8 (WASM exports). Phase 8 imports from the new core paths; Phase 7 must compile clean first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The PM coordinates you with Dev-A and Dev-B.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||
|
||||
- When you dispatch a subagent
|
||||
- When a subagent returns with a decision worth flagging (an unexpected coupling, an alternative API shape considered, a found-but-flagged out-of-scope issue)
|
||||
- When a sub-task completes (e.g. base32 module landed; `MonthYear::parse` integrated; first WASM export wired)
|
||||
- When you change direction or hit something unexpected
|
||||
- When you start a new phase
|
||||
|
||||
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "subagent surfaced a fourth base32 callsite in `crates/relicario-core/src/manifest.rs:??`; not in DEV-A P2's flagged list — escalating as a question"; "kept `MonthYear::parse` returning `Result<Self, RelicarioError>` rather than touching `MonthYear::new`'s `&'static str` per Plan B's recommendation; `new`-to-`RelicarioError` is DEV-A's separate P3"; "WASM exports compile clean; `wasm.d.ts` mirror passes `tsc --noEmit` in `extension/`." Print every STATUS UPDATE locally too.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message` and print here. Format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-C
|
||||
Time: <iso8601>
|
||||
Branch: feature/cli-tail-stream-c-core-wasm-seam
|
||||
Task: <phase number / sub-step>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <WHAT and WHY — 3 sentences max>
|
||||
```
|
||||
|
||||
**For PM input mid-task**:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-C
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within Phases 7-8
|
||||
|
||||
You don't need PM permission to:
|
||||
|
||||
- Execute sub-steps per Plan B's Phases 7 and 8
|
||||
- Make implementation decisions consistent with Plan B
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate when:
|
||||
|
||||
- A scope question outside Plan B Phases 7-8
|
||||
- A test you can't make green after honest debugging
|
||||
- A discovered bug not in Plan B
|
||||
- A fourth base32 implementation or a parser surfaces beyond DEV-A P2 + Plan B's three
|
||||
- Anything destructive (per `CLAUDE.md`)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
cd extension && npm run test # verify wasm.d.ts mirror compiles against TS callers
|
||||
```
|
||||
|
||||
All four must be green / clean. Then push and open the PR:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
git push -u origin feature/cli-tail-stream-c-core-wasm-seam
|
||||
gh pr create --base main --head feature/cli-tail-stream-c-core-wasm-seam --title "refactor(core,wasm): migrate parsers + base32 dedup + WASM exports (Plan B Phases 7, 8)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Phase 7 — `parse_month_year`, `base32_decode_lenient`, `guess_mime` migrated from CLI to `relicario-core` (`MonthYear::parse`, new `pub(crate) mod base32`, new `mime::guess_for_extension`); base32 dedup folds `crates/relicario-core/src/item.rs:255-275` and `import_lastpass.rs:202-220` into the new shared module (Steam alphabet untouched per neighbour comment)
|
||||
- Phase 8 — `#[wasm_bindgen]` exports for the three migrated parsers; `extension/src/wasm.d.ts` mirror updated in the same commit; snake_case JS naming consistent with existing exports
|
||||
- The CLI's `parse.rs` is a thin re-export shim; existing CLI callsites unchanged
|
||||
|
||||
## Plan B Phases 7-8
|
||||
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8.
|
||||
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||
|
||||
## Test plan
|
||||
- [x] cargo test --workspace
|
||||
- [x] cargo clippy --workspace
|
||||
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
- [x] cd extension && npm run test (verifies wasm.d.ts compiles)
|
||||
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||
- [x] Existing crates/relicario-core/tests/* pass without modification
|
||||
|
||||
## Out of scope (deferred)
|
||||
- Extension consumption of the new WASM exports — Plan C territory; no SW message handlers wired in this PR
|
||||
- camelCase JS naming for the three new exports — explicitly snake_case per Plan B; the camelCase decision is its own future plan
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-c-core-wasm-seam`), then start Phase 7 sub-step 1 (create `crates/relicario-core/src/base32.rs` with the unified `encode_rfc4648` / `decode_rfc4648_lenient` shape).
|
||||
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal file
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# PM Kickoff Prompt — CLI Tail (Cycle 2)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the CLI-tail cycle-2 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals.
|
||||
|
||||
This release has no version tag — it's the second cycle of the architecture-review structural-cleanup bundle. Cycle 1 shipped Plan A (security + docs polish) and Plan B Phases 1 + 2 (mechanical `main.rs` split + `git_run` helper). Cycle 2 partitions the remaining six Plan B phases (3 through 8) across three independent streams. Plan C (extension restructure) is *not* in cycle 2 — it stays pending until DEV-C bandwidth is available, on its own kickoff.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-09. Project rules in `CLAUDE.md` apply (Spanish flourish in chat replies only, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking, default to subagent-driven execution, force-cd subagents into their worktree).
|
||||
|
||||
**Pre-launch state assumed:** cycle-1 Stream A merged, cycle-1 Stream B PR (Phase 1 + 2) merged, working tree clean on `main`, relay server alive on `localhost:7331`. Verify with `git log --oneline -5` and `ss -ltn 'sport = :7331'` before sending opening directives. If either is not in place, surface to the user before proceeding.
|
||||
|
||||
## Cycle 1 outcomes (read for context — your context starts cold)
|
||||
|
||||
The cycle-1 four-agent run (`docs/superpowers/coordination/2026-05-04-arch-followup-*-prompt.md`) produced:
|
||||
|
||||
- **Stream A (security + docs polish)** merged to `main`. Key commits: `1e858e1` impl Drop for SessionHandle, `03d0781` SW free() unswallow, `229e483` recovery_qr.rs documentation, `f8296fa` rustdoc warning fix on a private intra-doc link, `0c9387f` start.sh fourth-window. Plan A complete.
|
||||
- **Stream B (CLI restructure Phases 1 + 2 only)** merged to `main` per a 2026-05-09 RESCOPE directive that halted Plan B at Phase 2 to enable cycle-2 parallelization. Key commits: `97c8f99` 15-site git_run sweep, `f3cdbed` git_run helper. `main.rs` shipped at 509 LOC (vs spec's ≤500); the 9-LOC overshoot is `#[arg(...)]` attribute density on 9 sub-enums and was accepted at merge — substance criterion (clap surface + dispatch + 2 shim families only) was met. DEV-B chose Plan B's option (b) for `git_run` (capture stderr + replay on failure) over option (a) (terminal-aware streaming).
|
||||
- **Stream C (extension restructure)** did NOT launch in cycle 1 (cycle-1 DEV-C never acked). Plan C remains pending and is *not* part of cycle 2 — it is a multi-week effort scheduled separately on its own kickoff.
|
||||
- **17 pre-existing extension test failures** on the kickoff baseline `bd3d53f` were documented in cycle-1 Stream A's PR. They sit in `extension/src/{service-worker,popup}/...` (devices/router/settings clusters) and pre-date the architecture review. Treat as the regression baseline: any cycle-2 red test outside this 17-failure cluster is a new regression and a stream's responsibility.
|
||||
|
||||
## Lessons learned (bake into your coordination)
|
||||
|
||||
Cycle 1 surfaced three operational gotchas worth pre-empting:
|
||||
|
||||
- **Prefer single-line relay message bodies.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals in body content. Compose `body` fields as a single line with sentences separated by periods; use ` -- ` for stronger breaks. The relay itself accepts multi-line bodies, but the consuming dev's monitor may not.
|
||||
- **Python f-string footgun in inbox-monitor scripts.** If a dev reports a `SyntaxError: unexpected character after line continuation character`, their polling script likely uses `print(f"... {m.get(\"from\")} ...")` — Python f-strings cannot contain backslash-escaped quotes inside brace expressions. Fix is single quotes: `m.get('from')`.
|
||||
- **Narration policy is non-negotiable.** Cycle 1 added it mid-run; cycle 2 has it baked into every kickoff. Devs MUST emit `Status: IN-PROGRESS` updates at meaningful in-flight moments (subagent dispatch, surprise findings, sub-task complete, phase start), not just at phase boundaries. You MUST narrate to the user in plain prose between tool calls — when a STATUS UPDATE lands, summarize it for the user before deciding; when you send a directive, state the rationale; when you dispatch a subagent, say so. Enforce both.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — **partition spec for this cycle. The canonical source for who owns what.**
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (phase definitions). Cycle 2 executes Phases 3 through 8.
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — original synthesis (read the P-tags Plan B addresses: P1.2, P1.3, P1.10, plus the four CLI P2s)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (line-level context the synthesis abbreviates)
|
||||
|
||||
You do NOT need to read Plans A or C in detail — they're out of cycle-2 scope. Skim the partition coordinator's "Cross-stream dependencies" section so you know what conflicts to watch for.
|
||||
|
||||
## Stream overview (from coordinator)
|
||||
|
||||
| Stream | Branch | Owner | Plan B phases | Theme |
|
||||
|---|---|---|---|---|
|
||||
| A | `feature/cli-tail-stream-a-prompt-helpers` | DEV-A | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
|
||||
| B | `feature/cli-tail-stream-b-session-manifest` | DEV-B | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
|
||||
| C | `feature/cli-tail-stream-c-core-wasm-seam` | DEV-C | Phases 7, 8 | parser migration to core + base32 dedup + WASM exports |
|
||||
|
||||
**No interface contracts between streams.** All three are independent once the cycle-1 PRs have merged. Conflict surface: `commands/add.rs` (A modifies builders; B modifies a manifest-mutation callsite). Whichever stream opens its PR second rebases.
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from each stream's feature branch
|
||||
- Edit `docs/`, `CLAUDE.md`, or other doc artifacts as needed; do not write feature code
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / `CLAUDE.md` are fine.
|
||||
- Don't deviate from Plan B's phase definitions without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag — no tag planned for this cycle.
|
||||
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`).
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. Use `post_message` / `read_messages` directly. Call `read_messages(for="pm")` before every action.
|
||||
|
||||
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is this terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly so the user sees the reasoning, not just the verdict. When you dispatch a subagent (e.g. for plan review or coherence pass), say so. One or two sentences per beat is plenty — the goal is for the user to read this terminal top-to-bottom and understand the release as a story.
|
||||
|
||||
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
|
||||
|
||||
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-<letter>
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user, give a current rollup:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — CLI Tail (Cycle 2)
|
||||
Devs: <per-dev one-line state>
|
||||
PM: <what you're working on>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Stream A REVIEW-READY", "all three streams merged">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check the diff against Plan B's "Done criteria" entries for that stream's phases
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash per project convention)
|
||||
5. If red: post `Action: HOLD` with specific concerns
|
||||
|
||||
Use `superpowers:requesting-code-review` if you want a deeper independent review from a fresh subagent before approving.
|
||||
|
||||
## Pre-merge checklist (per stream)
|
||||
|
||||
Before each `MERGE-APPROVED`:
|
||||
|
||||
- [ ] Plan B's "Done criteria" for the stream's owned phases all checked
|
||||
- [ ] `cargo test --workspace` green on the stream's worktree
|
||||
- [ ] `cargo clippy --workspace` silent
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, Stream C especially)
|
||||
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` pass without modification
|
||||
- [ ] Narration discipline observed in the PR's STATUS UPDATE history
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||
2. Verify pre-launch state: `git log --oneline -5 main`, `git status`, `ss -ltn 'sport = :7331'`. If any check fails, surface to the user before proceeding.
|
||||
3. Emit a `## RELEASE STATUS` block confirming context absorbed.
|
||||
4. Wait for setup-acknowledge STATUS UPDATEs from all three devs (their kickoff prompts have them post one after creating their worktree). Once all three are in, post opening `PROCEED` directives confirming each stream's plan path and phase scope.
|
||||
5. Standing watch: drain inbox before each action; respond to QUESTIONs and STATUS UPDATEs as they arrive.
|
||||
199
docs/superpowers/coordination/RELAY.md
Normal file
199
docs/superpowers/coordination/RELAY.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# RELAY — Multi-Agent Kickoff & Coordination
|
||||
|
||||
How to spin up parallel Claude Code sessions that coordinate over a shared MCP relay. One PM, two or more Devs, each in their own terminal, each on their own branch / worktree, exchanging structured messages.
|
||||
|
||||
## TL;DR — three commands
|
||||
|
||||
```bash
|
||||
# 1. Generate kickoff prompts (interactive — answers the design questions)
|
||||
# In any Claude Code session in this repo:
|
||||
/multi-agent-kickoff
|
||||
|
||||
# 2. Start the relay + open the windows
|
||||
bash tools/relay/start.sh --kitty # or --tmux, or --manual
|
||||
|
||||
# 3. In each new Claude window, paste the prompt below the `---` line
|
||||
# from the file the launcher prints (e.g. coordination/<date>-pm-prompt.md)
|
||||
```
|
||||
|
||||
That's the whole workflow. Everything below is the why and the troubleshooting.
|
||||
|
||||
## What this is
|
||||
|
||||
The "PM/Senior-Dev paradigm" — one Claude session acts as project manager, two or more Claude sessions act as senior developers, each running its own subagents on a feature branch in its own worktree. They coordinate by sending each other typed messages (status / question / directive / free) through a tiny MCP server running locally.
|
||||
|
||||
When to use it:
|
||||
|
||||
- You have **2+ implementation plans** that share a release target and want to execute them in parallel under one coordinator.
|
||||
- You want each stream isolated (separate worktree, separate branch) so subagents can't accidentally commit to main or step on each other's files.
|
||||
- You want one human (the user) to be a relay-of-last-resort but not a router — the PM does the routing.
|
||||
|
||||
When NOT to use it: one-off tasks, single-stream plans, anything where the overhead of "spin up four windows" exceeds the work itself. For those, just work in the foreground.
|
||||
|
||||
## The pieces
|
||||
|
||||
```
|
||||
┌──────────────────┐ HTTP/SSE ┌──────────────────┐
|
||||
│ Relay (MCP) │◀───────────│ PM session │
|
||||
│ tools/relay/ │ │ (Claude Code) │
|
||||
│ port 7331 │ └──────────────────┘
|
||||
│ │ ┌──────────────────┐
|
||||
│ Per-role inbox │◀───────────│ Dev-A session │
|
||||
│ in-memory │ │ (Claude Code) │
|
||||
│ consume-once │ └──────────────────┘
|
||||
└──────────────────┘ ┌──────────────────┐
|
||||
◀────────│ Dev-B session │
|
||||
│ (Claude Code) │
|
||||
└──────────────────┘
|
||||
┌──────── (optional) ───────┐
|
||||
◀────────│ Dev-C session │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
- **Relay MCP server** — `tools/relay/server.ts`. HTTP/SSE on `localhost:7331`. Exposes three MCP tools: `post_message`, `read_messages`, `list_pending`. Per-connection MCP server instance prevents routing collisions across concurrent SSE clients.
|
||||
- **In-memory queue** — `tools/relay/queue.ts`. Per-role inbox (`pm`, `dev-a`, `dev-b`, `dev-c`). `read` is consume-once (FIFO drain). No TTL, no persistence, no cap — relay is dev-only ephemeral; restart the server to wipe state.
|
||||
- **Launcher** — `tools/relay/start.sh`. Three modes (manual / tmux / kitty) that all start the relay and either open the role windows or print the commands for you to open them by hand.
|
||||
- **Fallback shim** — `tools/relay/call.py` (Python) and `tools/relay/call.ts` (TS). Direct CLI access to the same MCP tools, for when the in-Claude MCP client isn't loading or you want to script a status check from a regular shell. Both are tracked in the repo and load-bearing for the multi-agent flow — do not delete.
|
||||
|
||||
## Invocation
|
||||
|
||||
### Step 1 — Generate the kickoff prompts
|
||||
|
||||
In any Claude Code session inside this repo, run:
|
||||
|
||||
```
|
||||
/multi-agent-kickoff
|
||||
```
|
||||
|
||||
The skill walks you through a short Q&A (release target, branch names per dev, plan-file paths, coordination cadence) and writes four prompt files to `docs/superpowers/coordination/`:
|
||||
|
||||
```
|
||||
<date>-pm-prompt.md
|
||||
<date>-dev-a-prompt.md
|
||||
<date>-dev-b-prompt.md
|
||||
<date>-dev-c-prompt.md (only if 3 devs)
|
||||
```
|
||||
|
||||
Each prompt is self-contained: it tells the receiving session its role, its branch / worktree, the plan it owns, and the coordination protocol (block format, when to send status, who to escalate to). The launcher script discovers the latest `*-pm-prompt.md` / `*-dev-a-prompt.md` / etc. by `mtime`, so the most recently generated set wins automatically.
|
||||
|
||||
### Step 2 — Start the relay
|
||||
|
||||
Pick a launcher mode that matches your terminal setup.
|
||||
|
||||
**`--kitty` (recommended on kitty)**
|
||||
|
||||
```bash
|
||||
bash tools/relay/start.sh --kitty
|
||||
```
|
||||
|
||||
Opens 4 (or 5 with dev-c) tabs in the current kitty window: one for the relay log, one per role. Each role tab launches `claude` in the repo root. Paste the corresponding prompt into each role tab to start the session.
|
||||
|
||||
**`--tmux` (recommended on non-kitty)**
|
||||
|
||||
```bash
|
||||
bash tools/relay/start.sh --tmux
|
||||
```
|
||||
|
||||
Creates a tmux session `relay-lift` with windows `relay`, `pm`, `dev-a`, `dev-b` (and `dev-c` if a fourth prompt is found). Attaches automatically. `Ctrl-b N` to navigate windows. Detach with `Ctrl-b d`.
|
||||
|
||||
**`--manual` (for any terminal)**
|
||||
|
||||
```bash
|
||||
bash tools/relay/start.sh --manual
|
||||
```
|
||||
|
||||
Starts the relay in the current terminal and prints `cat <path>` commands for each role. Open new terminals yourself and paste the printed commands; this is the most flexible mode for unusual setups (split panes, remote sessions, terminal multiplexers other than tmux).
|
||||
|
||||
The launcher uses port **7331**. If it's already in use the script aborts with the kill command — `kill $(lsof -ti:7331)` clears it.
|
||||
|
||||
### Step 3 — Drive the coordination
|
||||
|
||||
The PM session is the entry point. Talk to PM about goals; PM decides who's working on what and posts directives to the dev sessions via the relay. Each dev reads its inbox, executes, and posts back status / questions. The user (you) is mostly a watcher — PM should self-route.
|
||||
|
||||
Common rhythm:
|
||||
|
||||
- **PM at start:** posts a directive to each dev describing the first slice.
|
||||
- **Dev on completion:** posts status with branch / commit / what shipped.
|
||||
- **Dev when blocked:** posts a question; PM unblocks (decision) or escalates to user.
|
||||
- **PM end-of-cycle:** asks each dev for a status, summarizes, decides next slice.
|
||||
|
||||
Message kinds (`MessageKind` in `queue.ts`):
|
||||
|
||||
| Kind | Use when |
|
||||
|------|----------|
|
||||
| `status` | "I shipped X, branch is at Y, ready for next slice" |
|
||||
| `question` | "Should I do A or B? Blocking until I hear back" |
|
||||
| `directive` | PM-to-dev: "Next, do X. Constraints are Y. Acceptance is Z." |
|
||||
| `free` | Anything that doesn't fit the above (FYI, side-channel chatter) |
|
||||
|
||||
Block format inside `body` is freeform markdown. The kickoff prompts include the project's preferred block templates.
|
||||
|
||||
## Fallback — when the MCP client misbehaves
|
||||
|
||||
If a Claude session can't reach the relay's MCP tools (transient SSE hiccup, MCP server failed to register, sandboxed network), use the shim:
|
||||
|
||||
```bash
|
||||
# From any shell, with the relay running on 7331:
|
||||
python3 tools/relay/call.py read_messages '{"for":"pm"}'
|
||||
python3 tools/relay/call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"shipped X"}'
|
||||
python3 tools/relay/call.py list_pending '{"for":"dev-b"}'
|
||||
```
|
||||
|
||||
`call.ts` is the same surface in TypeScript (`bun run tools/relay/call.ts ...`) for when you want to script from a TS context. Both shims speak raw MCP over the SSE transport; output is the JSON-RPC response.
|
||||
|
||||
The kickoff prompts reference `call.py` by path — if the in-Claude MCP client breaks mid-session, the dev can fall back to `Bash python3 tools/relay/call.py ...` and keep coordinating without restarting.
|
||||
|
||||
## Where things live
|
||||
|
||||
```
|
||||
docs/superpowers/coordination/
|
||||
├── RELAY.md ← you are here
|
||||
├── <date>-pm-prompt.md generated by /multi-agent-kickoff
|
||||
├── <date>-dev-a-prompt.md
|
||||
├── <date>-dev-b-prompt.md
|
||||
├── <date>-dev-c-prompt.md (optional, 4-role mode)
|
||||
└── archive/ older kickoff sets
|
||||
|
||||
tools/relay/
|
||||
├── start.sh launcher (manual / tmux / kitty)
|
||||
├── server.ts MCP server (HTTP/SSE on :7331)
|
||||
├── queue.ts in-memory per-role FIFO
|
||||
├── queue.test.ts node:test — run with `bun test`
|
||||
├── call.py Python MCP-client shim (fallback)
|
||||
├── call.ts TypeScript MCP-client shim (fallback)
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
The launcher's prompt-discovery is `ls -t "$COORD_DIR"/*-<role>-prompt.md | head -1` — newest wins. To switch back to a previous kickoff set, either delete the newer files or move them under `archive/`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Roles are fixed strings:** `pm`, `dev-a`, `dev-b`, `dev-c`. Adding a new role means editing `Role` in `queue.ts`, `KNOWN_ROLES`, the `enum` in `server.ts`'s tool schema, and the launcher.
|
||||
- **Worktree per dev:** each dev session works in its own git worktree on its own branch. Subagents must `cd` into the worktree first — the multi-agent-kickoff skill bakes this rule into the dev prompts (subagents have been known to commit to `main` if the worktree cwd is only set in a header).
|
||||
- **Branch naming follows the release train:** `feature/<release>-<dev>-<scope>`. PM owns the merge order; devs do not merge each other's branches.
|
||||
- **No squashing:** the project preserves git history as audit log (per `CLAUDE.md`). Devs commit small and often; PM coordinates rebases at integration time, not before.
|
||||
- **The user is not the router.** PM should issue directives directly to devs via the relay. The user steps in only for cross-stream design decisions or when PM explicitly escalates.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"port 7331 is already in use"** — another relay is running. `kill $(lsof -ti:7331)`, then re-run `start.sh`.
|
||||
- **Launcher can't find a prompt** (`(none found)` in the printed paths) — `/multi-agent-kickoff` hasn't been run yet, or all generated prompts are under `archive/`. Re-run the skill.
|
||||
- **Dev session committing to `main` instead of its worktree** — its subagent prompts are missing the force-`cd` header. Regenerate the dev prompt via `/multi-agent-kickoff` (the skill bakes in the cd rule) or hand-edit the prompt to start with `cd <worktree-path>`.
|
||||
- **MCP tools don't show up in the Claude session** — restart the session. If it persists, fall back to `call.py`. If `call.py` also fails, check the relay log window for stack traces; the SSE transport sometimes wedges if a client disconnects ungracefully.
|
||||
- **`bun test` failing in `tools/relay/`** — relay tests use `node:test` via bun. Run from `tools/relay/`, not the repo root: `cd tools/relay && bun test`. Extension tests use vitest and live elsewhere; don't conflate.
|
||||
- **One dev session is silent** — check `python3 tools/relay/call.py list_pending '{"for":"<role>"}'` from any shell. If the dev's inbox has unread messages, they may have crashed or detached. Open the role's window and resume.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **In-memory queue is dev-only.** Restart the relay = lose all queued messages. There is no persistence by design — coordination is meant to flow forward, not be replayable.
|
||||
- **No auth.** The relay binds to `localhost:7331` with no token. Don't expose the port; don't run on a shared machine.
|
||||
- **The relay is not a chat history.** `read_messages` drains the inbox. If you need to refer back to what was said, copy-paste into a session note or the PR description; don't expect the relay to remember.
|
||||
- **Context costs scale with session count.** Four parallel Claude sessions burn four context windows. Use this paradigm when the parallel speedup justifies the cost — for sequential work, one session is cheaper.
|
||||
|
||||
## See also
|
||||
|
||||
- `tools/relay/server.ts` — MCP tool definitions (`post_message`, `read_messages`, `list_pending`) and their schemas.
|
||||
- `tools/relay/queue.ts` — `Role` / `MessageKind` types; the canonical per-role-inbox semantics.
|
||||
- `docs/superpowers/coordination/<date>-pm-prompt.md` — the latest PM kickoff (the actual operational instructions PM runs by).
|
||||
- The `multi-agent-kickoff` skill — generates the kickoff prompt set.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Dev A Kickoff Prompt — Architecture Review (Rust core)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior reviewer** owning the Rust core review for Relicario's whole-codebase architecture audit (2026-05-04). You are one of three dev reviewers (A/B/C) reporting to a PM in another terminal. Your scope is `crates/relicario-core/` and its tests.
|
||||
|
||||
The user wants to be able to **read and understand this codebase and learn by tinkering**, even though they don't know Rust. Your review lens is therefore *architectural clarity for a smart newcomer*, not just correctness. Naming, layering, comments at non-obvious boundaries, dead code, leaky abstractions, opportunities to simplify — all in scope.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Stay on `main`. **Do not check out branches, do not create worktrees, do not modify code.** This is a review-only role.
|
||||
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
|
||||
- The working tree has uncommitted changes (manifest bumps, vault tweaks, relay tooling). Run `git status` once for awareness; otherwise review HEAD as the canonical state. Flag anything weird about the uncommitted state in your notes if it suggests an in-flight architectural issue.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each pass: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session (the relay server was not running when your session opened), use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-a"}'
|
||||
```
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — the foundational spec (threat model, crypto pipeline, format)
|
||||
3. `crates/relicario-core/src/lib.rs` — public API surface
|
||||
4. Then walk every file in `crates/relicario-core/src/` and `crates/relicario-core/tests/` deliberately. The point is depth, not coverage rate.
|
||||
|
||||
You are NOT required to read the other crates (DEV-B owns them) or the extension (DEV-C owns it). If something in core only makes sense by looking at how it's consumed, file a `## QUESTION TO PM` rather than crossing the boundary.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- `crates/relicario-core/src/*.rs` — every file
|
||||
- `crates/relicario-core/tests/*.rs` — integration tests
|
||||
- `crates/relicario-core/Cargo.toml` — dependencies and features
|
||||
|
||||
**Out of scope (other reviewers' territory):**
|
||||
- `crates/relicario-cli/`, `crates/relicario-server/`, `crates/relicario-wasm/` — DEV-B
|
||||
- `extension/`, `tools/relay/` — DEV-C
|
||||
- `docs/` outside the spec link above
|
||||
|
||||
**Hard rules:**
|
||||
- **No code changes.** Not even trivial doc-comment fixes. Surface in your notes; the user decides what to act on after PM synthesis.
|
||||
- No git commits, no branch creation, no destructive ops.
|
||||
- If you spend more than ~30 minutes on one file, post a status update with what you've found so far and move on. Cover the whole core, then return for depth.
|
||||
|
||||
## What to look for (review lens)
|
||||
|
||||
Walk every file in `crates/relicario-core/src/` and assess:
|
||||
|
||||
1. **Architectural clarity for a Rust newcomer.** Would a smart person who knows another language be able to follow this? Where are the surprise idioms, the silent assumptions, the "you have to know Rust to read this" cliffs?
|
||||
2. **Naming.** Are types, functions, fields named for what they mean? Any cryptic two-letter abbreviations? Names that lie about what they do?
|
||||
3. **Layering.** Is `relicario-core` actually platform-agnostic (no fs, no net, no git)? Any leaks? Any types that pretend to be pure data but hide I/O?
|
||||
4. **API ergonomics.** Are public types easy to construct and consume? Any footguns (e.g. easy-to-misuse nonces, builders that compile but corrupt)? Are errors descriptive?
|
||||
5. **Crypto correctness invariants.** Argon2id params, XChaCha20-Poly1305 nonce uniqueness, key zeroization, AAD usage, version/format gates. Are these enforced by types or by convention? If by convention, is that convention obvious?
|
||||
6. **DCT steganography (`imgsecret.rs`).** This is the most magical file. Does it have enough explanation for a reader to map code to spec section?
|
||||
7. **Comments.** Where there ARE comments, do they explain *why* (good) or *what* (rot)? Where they're missing, is it because the code is self-explanatory, or because nobody got around to it?
|
||||
8. **Tests.** Do they cover happy path AND format/crypto edge cases? Are test helpers well-named? Any dead test cases?
|
||||
9. **Dead code, unused features, abandoned experiments.** Use `cargo clippy -p relicario-core` and `cargo +nightly udeps -p relicario-core 2>/dev/null || true` if you have time, but mostly: just notice things.
|
||||
10. **Simplification opportunities.** Three similar lines is better than premature abstraction; but six similar match arms might want a helper. Note candidates; don't insist.
|
||||
|
||||
You may run `cargo build -p relicario-core`, `cargo test -p relicario-core`, `cargo clippy -p relicario-core` to confirm assumptions, but the review is not gated on green — it's gated on understanding.
|
||||
|
||||
## Output
|
||||
|
||||
Write your findings to `docs/superpowers/reviews/2026-05-04-dev-a-notes.md`. Create the `reviews/` directory with `mkdir -p` if it doesn't exist.
|
||||
|
||||
Structure:
|
||||
|
||||
```markdown
|
||||
# DEV-A Architecture Review Notes — Rust Core
|
||||
|
||||
## Summary
|
||||
3-5 sentences: what's the overall architectural shape, what's the strongest part, what's the weakest part.
|
||||
|
||||
## Findings (prioritized: P1 = address before more code lands, P2 = soon, P3 = nice-to-have)
|
||||
|
||||
### P1 — <short title>
|
||||
**File(s):** `crates/relicario-core/src/foo.rs:123`
|
||||
**Observation:** <what you saw>
|
||||
**Why it matters:** <especially for a Rust newcomer reading the code>
|
||||
**Suggested direction:** <one or two sentences; do NOT rewrite — just point>
|
||||
|
||||
(repeat for each finding, P1 first, then P2, then P3)
|
||||
|
||||
## File-by-file walk (one paragraph each)
|
||||
For every file in core, a paragraph: what it does, what's clear, what's not. Brief is fine — this is the appendix.
|
||||
|
||||
## Beginner-friendliness assessment
|
||||
A paragraph: how readable is this crate for a competent dev who has never written Rust? What single change would help most?
|
||||
```
|
||||
|
||||
The PM will synthesize your notes (plus DEV-B's and DEV-C's) into the final review doc.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
**Before each major pass** (e.g. starting file walk, switching to findings write-up): call `read_messages(for="dev-a")`.
|
||||
|
||||
**Status update format** (post via `post_message(from="dev-a", to="pm", kind="status", body="...")`, also print here):
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601>
|
||||
Phase: SETUP | READING | WALKING | WRITING | REVIEW-COMPLETE
|
||||
Files covered: <e.g. 8/19 src + 0/7 tests>
|
||||
Findings so far: <P1: N, P2: N, P3: N>
|
||||
Notes: <≤3 sentences>
|
||||
```
|
||||
|
||||
**Question format** (when you need PM input — e.g. you're unsure if something is in scope, or you suspect a cross-cutting issue):
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-A
|
||||
Time: <iso8601>
|
||||
Context: <what file, what concern>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
You'll receive `## DIRECTIVE TO DEV-A` blocks from the PM via relay. Acknowledge and act.
|
||||
|
||||
## Authority
|
||||
|
||||
You don't need PM permission to:
|
||||
- Decide reading order within scope
|
||||
- Decide whether a finding is P1/P2/P3
|
||||
- Use subagents to parallelize file reads (force-cd into `/home/alee/Sources/relicario` per project memory rule — every subagent prompt MUST start with `cd /home/alee/Sources/relicario` so the subagent doesn't wander)
|
||||
- Run `cargo` commands (build, test, clippy) read-only
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- You suspect an issue that crosses into DEV-B or DEV-C territory
|
||||
- A finding is so severe (e.g. exploitable crypto bug) that it deserves immediate attention
|
||||
- You're tempted to fix something inline (don't — escalate and let the PM/user decide)
|
||||
|
||||
## REVIEW-COMPLETE criteria
|
||||
|
||||
Before posting `Phase: REVIEW-COMPLETE`:
|
||||
- [ ] Every file in `crates/relicario-core/src/` walked
|
||||
- [ ] Every file in `crates/relicario-core/tests/` walked
|
||||
- [ ] Notes written to `docs/superpowers/reviews/2026-05-04-dev-a-notes.md`
|
||||
- [ ] At least one paragraph in the beginner-friendliness assessment
|
||||
- [ ] No P1 finding left vague — every P1 has a file:line and a suggested direction
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="dev-a")`.
|
||||
2. Read the project rules and the spec.
|
||||
3. Skim `lib.rs` and the file list under `crates/relicario-core/src/`.
|
||||
4. Emit a `## STATUS UPDATE` with `Phase: SETUP` confirming spec absorbed and file count to walk.
|
||||
5. Begin the file walk. Save findings as you go.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Dev B Kickoff Prompt — Architecture Review (Rust consumers: CLI, server, WASM)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior reviewer** owning the Rust consumer-layer review for Relicario's whole-codebase architecture audit (2026-05-04). You are one of three dev reviewers (A/B/C) reporting to a PM in another terminal. Your scope is the three Rust crates that consume `relicario-core`: `relicario-cli`, `relicario-server`, and `relicario-wasm`.
|
||||
|
||||
The user wants to be able to **read and understand this codebase and learn by tinkering**, even though they don't know Rust. Your review lens is therefore *architectural clarity for a smart newcomer*, not just correctness. Naming, layering, where business logic actually lives, comments at non-obvious boundaries, dead code, leaky abstractions, opportunities to simplify — all in scope.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Stay on `main`. **Do not check out branches, do not create worktrees, do not modify code.** This is a review-only role.
|
||||
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
|
||||
- The working tree has uncommitted changes (manifest bumps, vault tweaks, relay tooling). Run `git status` once for awareness; otherwise review HEAD as the canonical state. Flag anything weird about the uncommitted state in your notes if it suggests an in-flight architectural issue.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each pass: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-b"}'
|
||||
```
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — the foundational spec
|
||||
3. `crates/relicario-core/src/lib.rs` — **just** the public API surface (you do NOT review core; that's DEV-A — but you must understand what your crates depend on)
|
||||
4. Then walk each consumer crate deliberately:
|
||||
- `crates/relicario-cli/src/main.rs` then the rest of `crates/relicario-cli/src/` and `crates/relicario-cli/tests/`
|
||||
- `crates/relicario-server/src/main.rs` and `crates/relicario-server/tests/`
|
||||
- `crates/relicario-wasm/src/lib.rs` then `crates/relicario-wasm/src/`
|
||||
|
||||
You are NOT required to read deeply into `relicario-core` internals (DEV-A owns them) or the extension TypeScript (DEV-C owns it). The WASM crate is your boundary with DEV-C; review it from the Rust side, and trust DEV-C to review it from the JS side.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- `crates/relicario-cli/` — entire crate (src + tests + Cargo.toml)
|
||||
- `crates/relicario-server/` — entire crate
|
||||
- `crates/relicario-wasm/` — entire crate
|
||||
|
||||
**Out of scope (other reviewers' territory):**
|
||||
- `crates/relicario-core/` internals — DEV-A
|
||||
- `extension/`, `tools/relay/` — DEV-C
|
||||
- `docs/` outside the spec link above
|
||||
|
||||
**Hard rules:**
|
||||
- **No code changes.** Not even trivial. Surface in notes.
|
||||
- No git commits, no branch creation, no destructive ops.
|
||||
- If you spot a core-internal issue while you're poking at it, file a `## QUESTION TO PM` so DEV-A can be alerted; don't expand your own scope.
|
||||
|
||||
## What to look for (review lens)
|
||||
|
||||
For each crate, assess:
|
||||
|
||||
### relicario-cli (the user-facing binary)
|
||||
|
||||
1. **Command surface.** Does the clap structure match the spec's intended UX? Are subcommand names and flags discoverable? Any flags that exist but don't do what their name suggests?
|
||||
2. **Where business logic lives.** `relicario-core` should be doing the work; the CLI should be glue (parse args → call core → format output). Any place where the CLI has logic that should be in core?
|
||||
3. **Session handling.** `session.rs` holds `UnlockedVault` with the master key in `Zeroizing`. Are session lifetimes clear? Any path where the key outlives its window?
|
||||
4. **Filesystem and git.** The CLI is the only place that touches fs and shells out to git. Is `helpers.rs` doing this cleanly? Any path-handling bugs? Any place where `git_command` is invoked with insufficient error mapping?
|
||||
5. **Tests.** Integration tests under `crates/relicario-cli/tests/` — do they test the CLI surface or just re-test core? Are fixtures synthetic (per project convention)? Any mocked filesystem leaks?
|
||||
6. **Error UX.** When something goes wrong, does the user get a useful message, or does a `RelicarioError` get printed raw?
|
||||
|
||||
### relicario-server (Git pre-receive hook)
|
||||
|
||||
1. **Surface.** Two subcommands (`verify-commit`, `generate-hook`). Is the contract with Git clear? Does `generate-hook` produce a hook that's actually correct?
|
||||
2. **Trust model.** This is the only piece that runs server-side. Does it correctly enforce "the server only sees opaque ciphertext" — i.e. it never tries to decrypt, only verifies signatures/format?
|
||||
3. **Failure modes.** What happens if a malformed commit lands? Are rejections actionable for the pushing client?
|
||||
|
||||
### relicario-wasm (extension bridge)
|
||||
|
||||
1. **JS surface.** What does `#[wasm_bindgen]` actually expose? Is the API minimal and well-typed (`SessionHandle` opaque), or does it leak Rust internals?
|
||||
2. **Session handle pattern.** `SessionHandle` → `Zeroizing<[u8;32]>` indirection. Is the lifetime story sound? What happens when JS drops the handle?
|
||||
3. **Error mapping.** Rust errors → JS exceptions. Are messages useful on the JS side, or do they just say "internal error"?
|
||||
4. **Build target.** Does `cargo build -p relicario-wasm --target wasm32-unknown-unknown` succeed clean? Any feature flags that look gnarly?
|
||||
5. **Boundary with DEV-C.** What does the TS side need to know that isn't documented in the WASM crate? Note these for DEV-C cross-reference.
|
||||
|
||||
### Cross-cutting (all three crates)
|
||||
|
||||
1. **Naming.** Same questions as DEV-A.
|
||||
2. **Comments.** Same.
|
||||
3. **Layering.** Does each crate import only what it should? Any place where a consumer crate is reaching past `relicario-core`'s public API?
|
||||
4. **Dead code, abandoned features.** Notice things.
|
||||
5. **Simplification.** Where would a Rust newcomer trip? What's the single change that would help most?
|
||||
|
||||
You may run `cargo build`, `cargo test -p relicario-cli`, `cargo test -p relicario-server`, `cargo build -p relicario-wasm --target wasm32-unknown-unknown`, `cargo clippy --workspace`. The review is not gated on green; it's gated on understanding.
|
||||
|
||||
## Output
|
||||
|
||||
Write your findings to `docs/superpowers/reviews/2026-05-04-dev-b-notes.md`. Create the `reviews/` directory with `mkdir -p` if it doesn't exist.
|
||||
|
||||
Structure:
|
||||
|
||||
```markdown
|
||||
# DEV-B Architecture Review Notes — Rust Consumers (CLI, Server, WASM)
|
||||
|
||||
## Summary
|
||||
3-5 sentences spanning all three crates: what's the shape of the consumer layer, where is it strongest, where is it weakest.
|
||||
|
||||
## Findings (prioritized: P1 / P2 / P3)
|
||||
|
||||
Group by crate, P1 first within each:
|
||||
|
||||
### relicario-cli
|
||||
#### P1 — <short title>
|
||||
**File(s):** `crates/relicario-cli/src/main.rs:456`
|
||||
**Observation:** ...
|
||||
**Why it matters:** ...
|
||||
**Suggested direction:** ...
|
||||
|
||||
### relicario-server
|
||||
...
|
||||
|
||||
### relicario-wasm
|
||||
...
|
||||
|
||||
### Cross-cutting
|
||||
Findings that span more than one crate (e.g. an error-handling pattern that's inconsistent across all three).
|
||||
|
||||
## File-by-file walk
|
||||
One paragraph per file across all three crates. Appendix-grade detail.
|
||||
|
||||
## Boundary notes for DEV-C
|
||||
What about the WASM JS surface should DEV-C double-check from the TypeScript side?
|
||||
|
||||
## Beginner-friendliness assessment
|
||||
A paragraph: how readable are these crates for a competent dev who has never written Rust? What single change would help most?
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
**Before each major pass:** `read_messages(for="dev-b")`.
|
||||
|
||||
**Status update format** (post via `post_message(from="dev-b", to="pm", kind="status", body="...")`, also print here):
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601>
|
||||
Phase: SETUP | READING | WALKING-CLI | WALKING-SERVER | WALKING-WASM | WRITING | REVIEW-COMPLETE
|
||||
Crates covered: <e.g. cli ✓ / server ✓ / wasm ⏳>
|
||||
Findings so far: <P1: N, P2: N, P3: N>
|
||||
Notes: <≤3 sentences>
|
||||
```
|
||||
|
||||
**Question format:**
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-B
|
||||
Time: <iso8601>
|
||||
Context: <what crate, what concern>
|
||||
Options: <A / B / C>
|
||||
Recommended: <pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
You'll receive `## DIRECTIVE TO DEV-B` blocks from the PM via relay. Acknowledge and act.
|
||||
|
||||
## Authority
|
||||
|
||||
You don't need PM permission to:
|
||||
- Decide reading order within scope
|
||||
- Decide P1/P2/P3 prioritization
|
||||
- Use subagents to parallelize crate reads (force-cd into `/home/alee/Sources/relicario` per project memory rule — every subagent prompt MUST start with `cd /home/alee/Sources/relicario`)
|
||||
- Run `cargo` commands (build, test, clippy) read-only
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- An issue spans into DEV-A's core or DEV-C's TS territory
|
||||
- A finding is severe enough to deserve immediate attention
|
||||
- You're tempted to fix something inline (don't)
|
||||
|
||||
## REVIEW-COMPLETE criteria
|
||||
|
||||
- [ ] Every src file in cli/server/wasm walked
|
||||
- [ ] Every test file walked
|
||||
- [ ] Notes written to `docs/superpowers/reviews/2026-05-04-dev-b-notes.md`
|
||||
- [ ] Boundary-notes section for DEV-C populated
|
||||
- [ ] Every P1 has a file:line and suggested direction
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="dev-b")`.
|
||||
2. Read project rules and spec.
|
||||
3. Skim `relicario-core/src/lib.rs` for the public API your crates depend on.
|
||||
4. Emit a `## STATUS UPDATE` with `Phase: SETUP` confirming setup, listing crates+file counts you'll walk.
|
||||
5. Begin the walk: cli first, then server, then wasm. Save findings as you go.
|
||||
@@ -0,0 +1,214 @@
|
||||
# Dev C Kickoff Prompt — Architecture Review (TypeScript: extension + relay tooling)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior reviewer** owning the TypeScript review for Relicario's whole-codebase architecture audit (2026-05-04). You are one of three dev reviewers (A/B/C) reporting to a PM in another terminal. Your scope is the browser extension (`extension/`) and the dev tooling (`tools/relay/`).
|
||||
|
||||
The user wants to be able to **read and understand this codebase and learn by tinkering**, including the parts they're more comfortable with (TS). Your review lens is therefore *architectural clarity*, with extra attention to:
|
||||
- Parity with the CLI (per project memory: CLI/extension parity is a design philosophy — never ship "CLI first, extension follow-up")
|
||||
- The popup ↔ service-worker ↔ content script boundary
|
||||
- The WASM bridge from the JS side
|
||||
- Naming, layering, dead code, simplification opportunities
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Stay on `main`. **Do not check out branches, do not create worktrees, do not modify code.** This is a review-only role.
|
||||
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization, autonomy defaults, never run git-destructive commands without asking).
|
||||
- The working tree has uncommitted changes — note especially: `extension/manifest.json`, `extension/manifest.firefox.json`, `extension/package*.json`, `extension/src/shared/glyphs.ts` and `__tests__/glyphs.test.ts`, `extension/src/vault/vault.css`, `extension/src/vault/vault.ts`, `tools/relay/queue.ts`, `tools/relay/server.ts`, plus untracked `tools/relay/call.py`, `tools/relay/call.ts`, `.gitea_env_vars`. Run `git status` and `git diff` once. Decide whether the uncommitted diff is "in flight, ignore for review" or "already part of the architecture and worth flagging" — note your call in the notes file.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. Note that you are reviewing this server itself — but for coordination, you still use it. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each pass: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-c"}'
|
||||
```
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules (note especially the CLI/extension parity rule)
|
||||
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — foundational spec (skim, focus on the parts that touch the extension)
|
||||
3. `extension/package.json`, `extension/manifest.json`, `extension/manifest.firefox.json` — extension shape
|
||||
4. `extension/src/wasm.d.ts` and `extension/src/__stubs__/relicario_wasm.stub.ts` — your WASM boundary
|
||||
5. Then walk in this order:
|
||||
- `extension/src/service-worker/` (the trusted core of the extension)
|
||||
- `extension/src/shared/` (shared utilities)
|
||||
- `extension/src/popup/` (popup UI)
|
||||
- `extension/src/vault/` (full-tab vault UI)
|
||||
- `extension/src/content/` (content scripts: detector, fill, capture)
|
||||
- `extension/src/setup/` (setup wizard)
|
||||
- `tools/relay/` (the dev-only message bus, separate concern)
|
||||
|
||||
You are NOT required to read deeply into the Rust crates — DEV-A owns core, DEV-B owns CLI/server/WASM. From your side, you only need to understand the WASM JS surface that the extension consumes.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- `extension/` — entire tree (excluding `node_modules/` and built artifacts)
|
||||
- `tools/relay/` — entire tree (excluding `node_modules/`)
|
||||
- The WASM JS surface as consumed from TS (`wasm.d.ts`, the stub, every call site)
|
||||
|
||||
**Out of scope (other reviewers' territory):**
|
||||
- `crates/relicario-core/` internals — DEV-A
|
||||
- `crates/relicario-cli/`, `crates/relicario-server/`, `crates/relicario-wasm/` Rust internals — DEV-B
|
||||
- `docs/` outside the spec link above
|
||||
|
||||
**Hard rules:**
|
||||
- **No code changes.** Not even trivial. Surface in notes.
|
||||
- No git commits, no branch creation, no destructive ops.
|
||||
- If you find a Rust-side issue while looking at the WASM boundary, file a `## QUESTION TO PM` so DEV-B can be alerted; don't expand scope.
|
||||
|
||||
## What to look for (review lens)
|
||||
|
||||
### Extension architecture
|
||||
|
||||
1. **Layering — popup vs service-worker vs content vs vault tab.** Each has a clear role; are the lines clean? Any place where popup is doing trusted work that should live in the service-worker, or vice versa? Any direct WASM calls in content scripts (a security smell)?
|
||||
2. **Message-passing.** `extension/src/shared/messages.ts` and the service-worker router — is the typing tight? Are there messages that exist but are never sent, or sent but never handled?
|
||||
3. **WASM session handle.** The session handle is a number (an opaque pointer into the Rust session map). Where does it live? Who creates it, who drops it? Is the locked/unlocked state machine obvious from the code?
|
||||
4. **CLI/extension parity.** Walk through the CLI's commands (skim `crates/relicario-cli/src/main.rs` for the surface only) and check: for every CLI capability, does the extension have an equivalent UI path? If not, is that a deliberate choice or an oversight? This is a project-philosophy lens — flag any gap as a P1 architectural issue.
|
||||
5. **Component organization.** `extension/src/popup/components/` has settings, item-form, item-list, fields, generator-panel, etc. Are component boundaries clean? Any teardown leaks (the project has had teardown bugs before — see commit `ddfb95d` and `8baef5b`)?
|
||||
6. **Storage and state.** What lives in `chrome.storage.local`? In `chrome.storage.session`? Any sensitive data persisted that shouldn't be? Any cleartext secrets in DOM that survive teardown?
|
||||
7. **Settings architecture.** v0.5.1 just landed a unified left-nav settings (commit `bd6a301`). Is the new structure clean, or are there leftover bits from the old structure?
|
||||
|
||||
### Vault tab vs popup
|
||||
|
||||
8. **Two render targets, one component.** Settings (and possibly other components) render into both the popup and the vault tab. Is the rendering target abstracted, or are there `if (in popup)` branches? Note any pattern that breaks down.
|
||||
9. **Vault tab session timeout.** There's been work on this (per project memory). Is the session-timer logic in `service-worker/session-timer.ts` legible?
|
||||
|
||||
### Shared utilities
|
||||
|
||||
10. **`shared/types.ts`, `shared/messages.ts`, `shared/state.ts`.** Are these doing what their names suggest? Any `any`-leaking? Any types that should be generated from the WASM bindings instead of hand-maintained?
|
||||
11. **`shared/glyphs.ts`.** Per project rules, no inline emoji — all UI glyphs go through this. Is the abstraction tight? (Recent uncommitted edits here — note your read.)
|
||||
|
||||
### Relay tooling (`tools/relay/`)
|
||||
|
||||
12. **Single-purpose dev tool.** It's the message bus you're using right now. Is it appropriately isolated from the rest of the codebase (no cross-imports, separate package.json)? Is the role enum (`pm`, `dev-a`, `dev-b`, `dev-c`) maintained in lockstep across `queue.ts`, `server.ts`, and any client (`call.py`, `call.ts`)?
|
||||
13. **Untracked `call.py` and `call.ts`.** What are these for? Are they the documented fallback shims? Should they be tracked?
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
14. **Naming.** Are TS names crisp? Any that lie about behavior?
|
||||
15. **Dead code.** `bun run build` cleanly? Any imports unused? Any components rendered nowhere?
|
||||
16. **Simplification.** Where would a beginner trip? What's the single change that would help most?
|
||||
17. **Tests.** `extension/src/**/__tests__/*.test.ts` — meaningful coverage, or smoke-only?
|
||||
|
||||
You may run `bun run build`, `bun run build:firefox`, `bun run test`, `cd tools/relay && bun test`, `bun run lint` if available. Review is not gated on green; it's gated on understanding.
|
||||
|
||||
## Output
|
||||
|
||||
Write your findings to `docs/superpowers/reviews/2026-05-04-dev-c-notes.md`. Create the `reviews/` directory with `mkdir -p` if it doesn't exist.
|
||||
|
||||
Structure:
|
||||
|
||||
```markdown
|
||||
# DEV-C Architecture Review Notes — TypeScript (Extension + Relay)
|
||||
|
||||
## Summary
|
||||
3-5 sentences: shape of the TS layer, strongest part, weakest part. Note the CLI/extension parity status.
|
||||
|
||||
## Findings (prioritized: P1 / P2 / P3)
|
||||
|
||||
Group by area, P1 first within each:
|
||||
|
||||
### Extension — service-worker
|
||||
### Extension — popup + components
|
||||
### Extension — vault tab
|
||||
### Extension — content scripts
|
||||
### Extension — setup
|
||||
### Extension — shared utilities
|
||||
### WASM boundary (JS side)
|
||||
### Relay tooling
|
||||
### CLI/extension parity gaps
|
||||
### Cross-cutting
|
||||
|
||||
(use the same finding format as DEV-A/DEV-B: file:line, observation, why it matters, suggested direction)
|
||||
|
||||
## File-by-file walk
|
||||
One paragraph per file (or per small group of related files). Appendix-grade.
|
||||
|
||||
## CLI/extension parity table
|
||||
| CLI capability | Extension equivalent | Notes |
|
||||
|----------------|----------------------|-------|
|
||||
| ... | ✓ or ✗ or partial | ... |
|
||||
|
||||
## Boundary notes for DEV-B
|
||||
What about the WASM JS surface (or session handle, error mapping) does DEV-B need to double-check from the Rust side?
|
||||
|
||||
## Beginner-friendliness assessment
|
||||
A paragraph for the TS side specifically.
|
||||
|
||||
## Uncommitted-state read
|
||||
A short paragraph: what's in the working tree but not in HEAD, and is any of it architecturally relevant?
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
**Before each major pass:** `read_messages(for="dev-c")`.
|
||||
|
||||
**Status update format** (post via `post_message(from="dev-c", to="pm", kind="status", body="...")`, also print here):
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-C
|
||||
Time: <iso8601>
|
||||
Phase: SETUP | READING | WALKING-SW | WALKING-POPUP | WALKING-VAULT | WALKING-CONTENT | WALKING-SETUP | WALKING-SHARED | WALKING-RELAY | WRITING | REVIEW-COMPLETE
|
||||
Areas covered: <checklist>
|
||||
Findings so far: <P1: N, P2: N, P3: N>
|
||||
Notes: <≤3 sentences>
|
||||
```
|
||||
|
||||
**Question format:**
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-C
|
||||
Time: <iso8601>
|
||||
Context: <what file, what concern>
|
||||
Options: <A / B / C>
|
||||
Recommended: <pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
You'll receive `## DIRECTIVE TO DEV-C` blocks from the PM via relay. Acknowledge and act.
|
||||
|
||||
## Authority
|
||||
|
||||
You don't need PM permission to:
|
||||
- Decide reading order within scope
|
||||
- Decide P1/P2/P3 prioritization
|
||||
- Use subagents to parallelize area reads (force-cd into `/home/alee/Sources/relicario` per project memory rule — every subagent prompt MUST start with `cd /home/alee/Sources/relicario`)
|
||||
- Run `bun` and `cargo` commands read-only
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- A finding spans into DEV-A's core or DEV-B's Rust crates
|
||||
- A finding is severe (e.g. a real security smell, leaked secret in storage)
|
||||
- You're tempted to fix something inline (don't)
|
||||
|
||||
## REVIEW-COMPLETE criteria
|
||||
|
||||
- [ ] Every TS file under `extension/src/` walked (including `__tests__`)
|
||||
- [ ] Every TS file under `tools/relay/` walked
|
||||
- [ ] Notes written to `docs/superpowers/reviews/2026-05-04-dev-c-notes.md`
|
||||
- [ ] CLI/extension parity table populated (every CLI capability either ✓, ✗, or partial)
|
||||
- [ ] Boundary-notes section for DEV-B populated
|
||||
- [ ] Uncommitted-state read paragraph written
|
||||
- [ ] Every P1 has a file:line and suggested direction
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="dev-c")`.
|
||||
2. Read project rules and spec.
|
||||
3. Look at `manifest.json` and skim `wasm.d.ts` so you know your boundaries.
|
||||
4. Run `git status` and `git diff` once for awareness of uncommitted state.
|
||||
5. Emit a `## STATUS UPDATE` with `Phase: SETUP` confirming setup, listing area buckets you'll walk.
|
||||
6. Begin the walk: service-worker first, then shared, then popup, then vault, then content, then setup, then relay tooling. Save findings as you go.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Dev A Kickoff Prompt — Architecture Review Followup, Stream A (Security & Docs Polish)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement the plan task-by-task once it lands.
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream A for the Relicario architecture-review-followup work. Stream A is the small, security-flavored quick-win PR that goes first: SessionHandle Drop semantics, the matching JS-side audit, recovery_qr.rs documentation density, and the relay launcher fix for the dev-c fourth window. Goal is **under-a-day, all-S effort, ships first**.
|
||||
|
||||
A PM in another terminal coordinates you with two other senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed. **Your plan does not exist yet.** The PM is drafting it as their first action. Set up your worktree, post acknowledgement, and wait for the PM's `PROCEED` directive containing the plan path before starting Task 1.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git worktree add /home/alee/Sources/relicario.arch-followup-stream-a -b feature/arch-followup-stream-a-security-polish
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-a
|
||||
pwd # should print /home/alee/Sources/relicario.arch-followup-stream-a
|
||||
```
|
||||
|
||||
**Note on `git pull`:** main has uncommitted polish changes from in-flight post-v0.5.1 work. The setup above branches from local main HEAD without pulling, which is intentional. If `git fetch` reveals new upstream commits, surface them to the PM before incorporating.
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.arch-followup-stream-a`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.arch-followup-stream-a` (project memory rule — without the force-cd, subagents may commit to main).
|
||||
|
||||
Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking).
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.1, the JS free-swallow fix, P1.7, P1.8 only**)
|
||||
3. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — full DEV-A reviewer notes (Rust core; covers recovery_qr.rs context)
|
||||
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — full DEV-B reviewer notes (covers SessionHandle context on the WASM side)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — full DEV-C reviewer notes (covers the JS free-swallow + start.sh launcher context)
|
||||
6. **Your plan (will land at):** `docs/superpowers/specs/2026-05-04-security-polish-design.md` — read once the PM directs you to PROCEED
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **superpowers:subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
|
||||
```
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-a
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- P1.1 — `impl Drop for SessionHandle` in `crates/relicario-wasm/src/lib.rs` (and `session.rs`); `wasm-bindgen-test` for construct → drop → confirm `SESSIONS` registry empty; audit every `.free()` callsite under `extension/src/` to confirm `wasm.lock(handle)` happens first regardless
|
||||
- JS partner fix at `extension/src/service-worker/session.ts:26` — remove the `try { current.free() }` swallow; let exceptions propagate or log + counter
|
||||
- P1.7 — `crates/relicario-core/src/recovery_qr.rs` documentation pass to match `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs` density
|
||||
- P1.8 — `tools/relay/start.sh:80` launcher fix for the dev-c fourth window (the queue.test.ts assertion is already fixed in `061facd`; only the launcher line remains)
|
||||
|
||||
**Out of scope (other DEVs own these):**
|
||||
- P1.2, P1.3, P1.10 + the in-scope CLI P2s — DEV-B (Stream B)
|
||||
- P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2s — DEV-C (Stream C)
|
||||
- Any other P2/P3 not listed in your plan
|
||||
- The 8 "Open architectural decisions" at the bottom of the synthesis — those are user-judgement calls
|
||||
|
||||
If you trip over an out-of-scope issue or a new bug while doing your work, file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- **P1.1 is defense-in-depth crypto.** The `wasm-bindgen-test` covering construct → drop → registry-empty is a required acceptance gate. Do not skip or weaken it.
|
||||
- **The `.free()` callsite audit is part of P1.1.** Every callsite under `extension/src/` must be checked to confirm `wasm.lock(handle)` is called before `.free()`. Document the audit results in the PR.
|
||||
- **Do not remove the JS free-swallow before `impl Drop` lands.** The order matters: Rust fix first (so `.free()` becomes meaningful cleanup), JS swallow removal second (so failures surface).
|
||||
- **`recovery_qr.rs` docs must match the existing core standard.** Multi-paragraph module-level `//!` rationale, ASCII diagram of the 109-byte layout, doc-comments on the four public functions, comment or `const` for the `production_params()` divergence. Read `crypto.rs` and `backup.rs` first to absorb the tone.
|
||||
- **Test-only Argon2id params stay fast** (m=256, t=1, p=1) per project convention.
|
||||
- Synthetic JPEG fixtures only — no binary blobs in the test suite (project rule).
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-a"}'
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. Before starting each task, call `read_messages(for="dev-a")` to drain your inbox.
|
||||
|
||||
When posting a status update, call `post_message(from="dev-a", to="pm", kind="status", body="...")` with the body:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
|
||||
Branch: feature/arch-followup-stream-a-security-polish
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task:**
|
||||
```
|
||||
## QUESTION TO PM — DEV-A
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
**You'll receive:** `## DIRECTIVE TO DEV-A` blocks from the PM. The first one will say `Action: PROCEED` with the plan path once the PM has drafted it. Acknowledge and start Task 1.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute task-to-task per the plan once you have it
|
||||
- Make implementation decisions consistent with the plan and synthesis
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- A scope question outside the plan
|
||||
- A test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- Anything destructive (per CLAUDE.md)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-a
|
||||
cargo test
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
cd extension && bun run test && bun run build && cd ..
|
||||
cd tools/relay && bun test && cd ../..
|
||||
```
|
||||
|
||||
All four must be green. The wasm-bindgen-test for SessionHandle Drop is the headline acceptance.
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/arch-followup-stream-a-security-polish
|
||||
gh pr create --base main --head feature/arch-followup-stream-a-security-polish \
|
||||
--title "fix(security/docs): SessionHandle Drop + free-swallow + recovery_qr docs + dev-c launcher (Stream A)" \
|
||||
--body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Stream A of the architecture-review-followup. Small security-flavored polish PR addressing four findings from the 2026-05-04 audit:
|
||||
|
||||
- **P1.1** — `impl Drop for SessionHandle` so `.free()` becomes a real cleanup safety net (Rust fix); `wasm-bindgen-test` for construct → drop → registry empty; audit of every `.free()` callsite under `extension/src/`
|
||||
- **JS free-swallow** — removed `try { current.free() }` at `extension/src/service-worker/session.ts:26` so failures propagate
|
||||
- **P1.7** — `crates/relicario-core/src/recovery_qr.rs` brought up to the documentation density of `crypto.rs` / `backup.rs` / `tar_safe.rs`
|
||||
- **P1.8** — `tools/relay/start.sh:80` launcher fix for the dev-c fourth window
|
||||
|
||||
## Synthesis references
|
||||
|
||||
- `docs/superpowers/reviews/2026-05-04-architecture-review.md` — P1.1, P1.7, P1.8
|
||||
- `docs/superpowers/specs/2026-05-04-security-polish-design.md` — Plan A
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] `cargo test` green
|
||||
- [x] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
|
||||
- [x] `cd extension && bun run test && bun run build` green
|
||||
- [x] `cd tools/relay && bun test` green (4-pass after dev-c assertion fix in 061facd)
|
||||
- [x] New wasm-bindgen-test: SessionHandle construct → drop → SESSIONS registry empty
|
||||
- [x] `.free()` callsite audit under `extension/src/` — every site preceded by `wasm.lock(handle)`
|
||||
- [x] `start.sh` opens 4 windows including dev-c
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading the required inputs and setting up the worktree:
|
||||
|
||||
1. Call `read_messages(for="dev-a")` to drain any early inbox messages.
|
||||
2. Emit a `## STATUS UPDATE` confirming setup complete:
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601>
|
||||
Branch: feature/arch-followup-stream-a-security-polish
|
||||
Task: setup
|
||||
Status: DONE
|
||||
Last commit: <main HEAD sha + first line>
|
||||
Tests: N/A
|
||||
Notes: Worktree at /home/alee/Sources/relicario.arch-followup-stream-a. Synthesis + reviewer notes absorbed. Awaiting Plan A at docs/superpowers/specs/2026-05-04-security-polish-design.md.
|
||||
```
|
||||
3. **Wait** for the PM's `## DIRECTIVE TO DEV-A` with `Action: PROCEED` and the plan path.
|
||||
4. Read the plan, then start Task 1 using `superpowers:subagent-driven-development`.
|
||||
@@ -0,0 +1,242 @@
|
||||
# Dev B Kickoff Prompt — Architecture Review Followup, Stream B (CLI Restructure)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement the plan task-by-task once it lands.
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream B for the Relicario architecture-review-followup work. Stream B is the **single biggest readability lift** in the bundle (per the synthesis): split the 2641-LOC `crates/relicario-cli/src/main.rs` into a `commands/` folder, centralize the duplicated git-shell error UX, migrate three pure parsers to `relicario-core`, and pick up four in-scope CLI P2s. Multi-day, M-L effort.
|
||||
|
||||
A PM in another terminal coordinates you with two other senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly. **Your plan does not exist yet.** The PM is drafting it as their first action. Set up your worktree, post acknowledgement, and wait for the PM's `PROCEED` directive containing the plan path before starting Task 1.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git worktree add /home/alee/Sources/relicario.arch-followup-stream-b -b feature/arch-followup-stream-b-cli-restructure
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-b
|
||||
pwd # should print /home/alee/Sources/relicario.arch-followup-stream-b
|
||||
```
|
||||
|
||||
**Note on `git pull`:** main has uncommitted polish changes from in-flight post-v0.5.1 work. The setup above branches from local main HEAD without pulling, which is intentional. None of those uncommitted files are in Stream B's scope, so this should not produce conflicts at PR time.
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.arch-followup-stream-b`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.arch-followup-stream-b` (project memory rule — without the force-cd, subagents may commit to main).
|
||||
|
||||
Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking).
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.2, P1.3, P1.10 + the four in-scope CLI P2s only**)
|
||||
3. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — full DEV-B reviewer notes (your stream's primary source)
|
||||
4. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — full DEV-A reviewer notes (covers the base32 dedup that pairs with P1.10)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read the **"Boundary notes for DEV-B"** section in particular (covers the parity-relevant cross-references for the parser→core migration and WASM exports)
|
||||
6. **Your plan (will land at):** `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — read once the PM directs you to PROCEED
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **superpowers:subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
|
||||
```
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-b
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- P1.2 — split `crates/relicario-cli/src/main.rs` (2641 LOC) into:
|
||||
- `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs`
|
||||
- `prompt.rs` (six `prompt_*` helpers + `prompt_secret`)
|
||||
- `parse.rs` (the three pure parsers, before they migrate to core)
|
||||
- `main.rs` keeps clap definitions + dispatcher (~470 lines)
|
||||
- P1.3 — add `helpers::git_run(repo: &Path, args: &[&str], context: &str)` using `.output()` to capture stderr; print captured stderr unmodified on failure; embed a human-readable `context` ("commit add: GitHub", "register device", "purge trashed item"). Sweep ~16 duplicated bail sites listed in the synthesis (`main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` and others).
|
||||
- P1.10 — migrate to `relicario-core`:
|
||||
- `parse_month_year` (already partial in `time.rs`)
|
||||
- `base32_decode_lenient` (rename / fold into a new `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648`; keep Steam's bespoke alphabet untouched in `item_types/totp.rs`)
|
||||
- `guess_mime` → `mime::guess_for_extension`
|
||||
- Re-export the new functions through `relicario-wasm` via `#[wasm_bindgen]` so the extension can consume them in a future round
|
||||
- CLI keeps thin wrappers as needed
|
||||
- In-scope CLI P2s:
|
||||
- `build_*_item` helper compression (`main.rs:664-921`) with a `prompt_or_flag<T>` helper
|
||||
- `refresh_groups_cache` discipline via `Vault::after_manifest_change(&self, manifest: &Manifest)` (7 manual sites at `main.rs:641, 998, 1123, 1197, 1414, 1432, 1474`)
|
||||
- `ParamsFile` dedup between `main.rs:2287` (write side, has `aead`/`salt_path`/`format_version`) and `session.rs:114` (read side, only `kdf`)
|
||||
- Batched purge in `cmd_purge` and `cmd_trash_empty` (`main.rs:1476-1480, 1896-1900`) — currently 50-item purge does 150 git invocations
|
||||
|
||||
**Out of scope (other DEVs own these):**
|
||||
- P1.1, JS free-swallow, P1.7, P1.8 — DEV-A (Stream A)
|
||||
- P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2s — DEV-C (Stream C)
|
||||
- Any other P2/P3 not listed in your plan
|
||||
- WASM JS-naming snake_case → camelCase rename (DEV-B/DEV-C P3) — explicitly deferred to a separate decision
|
||||
- Server hardening items from DEV-B's P2 (generate-hook PATH, bootstrap permissiveness, stdin parsing, test gaps) — explicitly deferred
|
||||
- Any of the 8 "Open architectural decisions" at the bottom of the synthesis
|
||||
|
||||
If you trip over an out-of-scope issue or a new bug while doing your work, file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- **The P1.2 main.rs split MUST land first.** Every other phase in your plan touches files that don't exist yet until the split happens. Do not interleave.
|
||||
- **The split is purely structural — no behavior changes.** Run `cargo test -p relicario-cli` before AND after the split commits to prove parity. Any behavior delta is a bug.
|
||||
- **Keep the helper module visibility tight.** `commands/` modules are `pub(crate)` only; `main.rs` dispatches into them.
|
||||
- **`helpers::git_run` signature** is `(repo: &Path, args: &[&str], context: &str)` and it captures stderr. The old `git_command` may stay temporarily during the sweep, but Phase B-N (sweep) deletes it.
|
||||
- **`base32` extraction** must keep Steam's bespoke alphabet untouched at `item_types/totp.rs`. Add a neighbour comment pointing to the new core module and explaining why Steam stays separate.
|
||||
- **WASM re-exports use snake_case** in JS for now (`#[wasm_bindgen]` defaults). Do not rename to camelCase — that's a separate decision (DEV-B/DEV-C P3 at the bottom of the synthesis).
|
||||
- **Test-only Argon2id params stay fast** (m=256, t=1, p=1) per project convention.
|
||||
- **No binary test fixtures.** Synthetic JPEGs only via `make_test_jpeg()`.
|
||||
- **Git history is preserved.** No squash merges (project rule).
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination with Stream C
|
||||
|
||||
Your parser→core migration produces new `relicario-core` functions and re-exports them through `relicario-wasm`. Stream C does NOT need to consume these in this round. Document the new WASM surface in your PR body so a future round can pick it up. If you discover a parser signature change that would break the future extension consumer, raise it via `## QUESTION TO PM` before committing.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-b"}'
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. Before starting each task, call `read_messages(for="dev-b")` to drain your inbox.
|
||||
|
||||
When posting a status update, call `post_message(from="dev-b", to="pm", kind="status", body="...")` with the body:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
|
||||
Branch: feature/arch-followup-stream-b-cli-restructure
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task:**
|
||||
```
|
||||
## QUESTION TO PM — DEV-B
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
**You'll receive:** `## DIRECTIVE TO DEV-B` blocks from the PM. The first one will say `Action: PROCEED` with the plan path once the PM has drafted it. Acknowledge and start Task 1.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute task-to-task per the plan once you have it
|
||||
- Make implementation decisions consistent with the plan and synthesis
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- A scope question outside the plan
|
||||
- A signature change in the parser→core migration that would affect the future extension consumer
|
||||
- A test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- Anything destructive (per CLAUDE.md)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-b
|
||||
cargo build
|
||||
cargo test
|
||||
cargo test -p relicario-core
|
||||
cargo test -p relicario-cli --test basic_flows
|
||||
cargo test -p relicario-cli --test edit_and_history
|
||||
cargo test -p relicario-cli --test attachments
|
||||
cargo test -p relicario-cli --test settings
|
||||
cargo test -p relicario-cli --test vault_detection
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
cargo run -p relicario-cli -- --help
|
||||
cargo run -p relicario-cli -- generate --length 32
|
||||
```
|
||||
|
||||
All must be green. The CLI integration suite covers the surface area touched by the split + git_run sweep; pay particular attention to `basic_flows` and `edit_and_history` for regression coverage.
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/arch-followup-stream-b-cli-restructure
|
||||
gh pr create --base main --head feature/arch-followup-stream-b-cli-restructure \
|
||||
--title "refactor(cli): split main.rs into commands/ + git_run helper + parsers→core (Stream B)" \
|
||||
--body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Stream B of the architecture-review-followup. The single biggest readability lift in the bundle:
|
||||
|
||||
- **P1.2** — `crates/relicario-cli/src/main.rs` (2641 LOC) split into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs` + `prompt.rs` + `parse.rs`. `main.rs` keeps clap definitions + dispatcher.
|
||||
- **P1.3** — `helpers::git_run(repo, args, context)` with stderr capture; ~16 duplicated bail sites swept.
|
||||
- **P1.10** — `parse_month_year`, `base32_decode_lenient` (folded into a new `relicario_core::base32` module alongside DEV-A's P2 dedup), `guess_mime` migrated to `relicario-core`. Re-exported through `relicario-wasm` via `#[wasm_bindgen]` for future extension consumption.
|
||||
- **CLI P2 cluster** — `build_*_item` helper compression, `Vault::after_manifest_change`, `ParamsFile` dedup, batched purge.
|
||||
|
||||
## Synthesis references
|
||||
|
||||
- `docs/superpowers/reviews/2026-05-04-architecture-review.md` — P1.2, P1.3, P1.10 + DEV-B's CLI P2 cluster + DEV-A's base32 dedup
|
||||
- `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B
|
||||
|
||||
## New WASM surface (for future Stream C consumption)
|
||||
|
||||
The extension does NOT consume these in this round. A future plan should wire them up via `wasm.d.ts` regeneration:
|
||||
- `parse_month_year` — for QR-import / smart-input flows
|
||||
- `base32_encode_rfc4648` / `base32_decode_rfc4648` — for TOTP / future QR work
|
||||
- `guess_mime_for_extension` — for attachment MIME detection
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] `cargo build` green
|
||||
- [x] `cargo test` green (workspace-wide)
|
||||
- [x] `cargo test -p relicario-core` green (new base32 module + parser tests)
|
||||
- [x] `cargo test -p relicario-cli --test basic_flows` green (regression on the split)
|
||||
- [x] `cargo test -p relicario-cli --test edit_and_history` green
|
||||
- [x] `cargo test -p relicario-cli --test attachments` green
|
||||
- [x] `cargo test -p relicario-cli --test settings` green
|
||||
- [x] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green (new exports)
|
||||
- [x] `cargo run -p relicario-cli -- --help` displays unchanged surface
|
||||
- [x] `cargo run -p relicario-cli -- generate --length 32` smoke
|
||||
- [x] git_run sweep: every git failure path now prints captured stderr + context
|
||||
- [x] Steam alphabet at `item_types/totp.rs` untouched
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading the required inputs and setting up the worktree:
|
||||
|
||||
1. Call `read_messages(for="dev-b")` to drain any early inbox messages.
|
||||
2. Emit a `## STATUS UPDATE` confirming setup complete:
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601>
|
||||
Branch: feature/arch-followup-stream-b-cli-restructure
|
||||
Task: setup
|
||||
Status: DONE
|
||||
Last commit: <main HEAD sha + first line>
|
||||
Tests: N/A
|
||||
Notes: Worktree at /home/alee/Sources/relicario.arch-followup-stream-b. Synthesis + DEV-B/DEV-A notes + DEV-C boundary section absorbed. Awaiting Plan B at docs/superpowers/specs/2026-05-04-cli-restructure-design.md.
|
||||
```
|
||||
3. **Wait** for the PM's `## DIRECTIVE TO DEV-B` with `Action: PROCEED` and the plan path.
|
||||
4. Read the plan, then start Task 1 using `superpowers:subagent-driven-development`. Phase 1 is the main.rs split — finish it cleanly before any other phase.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Dev C Kickoff Prompt — Architecture Review Followup, Stream C (Extension Restructure)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement the plan task-by-task once it lands.
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream C for the Relicario architecture-review-followup work. Stream C is the **largest plan in the bundle** (multi-day to multi-week): turn `setup.ts` into a UI that posts SW messages instead of orchestrating WASM directly, split `vault.ts` into focused modules, give `shared/state.ts` a real type contract, deduplicate the SW router helpers, and pick up the in-scope extension P2 cluster.
|
||||
|
||||
A PM in another terminal coordinates you with two other senior devs. With the relay server running, you communicate via `post_message` / `read_messages` directly. **Your plan does not exist yet.** The PM is drafting it as their first action. Set up your worktree, post acknowledgement, and wait for the PM's `PROCEED` directive containing the plan path before starting Task 1.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git worktree add /home/alee/Sources/relicario.arch-followup-stream-c -b feature/arch-followup-stream-c-extension-restructure
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-c
|
||||
pwd # should print /home/alee/Sources/relicario.arch-followup-stream-c
|
||||
```
|
||||
|
||||
**Note on `git pull`:** main has uncommitted polish changes from in-flight post-v0.5.1 work, including edits to `extension/src/vault/vault.ts`, `vault.css`, and `shared/glyphs.ts` — files Stream C will touch heavily. **Surface this to the PM as a Question before starting Phase 1** so the user can choose to commit/stash the in-flight work, defer Stream C, or proceed and merge at PR time. The setup above branches from local main HEAD without pulling, so your worktree starts clean of those uncommitted edits.
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.arch-followup-stream-c`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.arch-followup-stream-c` (project memory rule — without the force-cd, subagents may commit to main).
|
||||
|
||||
Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking).
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (your scope is **P1.4, P1.5, P1.6, P1.9 + the in-scope extension P2 cluster only**)
|
||||
3. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — full DEV-C reviewer notes (your stream's primary source)
|
||||
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — read the **"Boundary notes for DEV-C"** section in particular (covers the parity-relevant cross-references, especially the new WASM surface that Plan B will produce)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — light skim for context only
|
||||
6. **Your plan (will land at):** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — read once the PM directs you to PROCEED
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **superpowers:subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
|
||||
```
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-c
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:**
|
||||
- P1.4 — `extension/src/setup/setup.ts` (1220 LOC):
|
||||
- Add `create_vault` and `attach_vault` SW messages to `service-worker/router/popup-only.ts` + the message-type union in `shared/messages.ts`
|
||||
- Rewrite `setup.ts` as a UI that posts those messages with gathered config + image bytes (no direct `relicario-wasm` import)
|
||||
- Convert the 6-step procedural wizard into a step-registry pattern: `Array<{ id: StepId; render: (host) => void; attach: (host) => () => void }>`
|
||||
- `clearWizardState()` bound to `beforeunload` and to "return to step 0"
|
||||
- Target ~500 LOC after the rewrite (down from 1220)
|
||||
- P1.5 — split `extension/src/vault/vault.ts` (1027 LOC) into:
|
||||
- `vault-shell.ts` (init, hash routing)
|
||||
- `vault-sidebar.ts` (sidebar render)
|
||||
- `vault-list.ts` (list pane render)
|
||||
- `vault-drawer.ts` (drawer state + render)
|
||||
- `vault-form-wrapper.ts` (form integration)
|
||||
- `vault.ts` keeps only routing + state coordination
|
||||
- **Lift the `vault_locked` RPC intercept into `shared/state.ts`** (or a wrapper around `sendMessage`) so popup and vault tab use the same channel — closes the synthesis P2 about RPC vs `session_expired` event divergence
|
||||
- Reset `state.drawerOpen` at the start of `renderPane` for non-list views
|
||||
- P1.6 — concrete `StateHost` interface in `extension/src/shared/state.ts`:
|
||||
```ts
|
||||
interface StateHost {
|
||||
state: PopupState;
|
||||
navigate: (view: View) => void;
|
||||
popOutToTab(): void;
|
||||
isInTab(): boolean;
|
||||
openVaultTab(hash?: string): void;
|
||||
}
|
||||
```
|
||||
- Make `getState`/`setState` generic over `keyof PopupState`
|
||||
- Throw on `registerHost()` re-register
|
||||
- Export `__resetHostForTests()` helper
|
||||
- P1.9 — extract from both router files:
|
||||
- `loadDeviceSettings`, `loadBlacklist`, `saveBlacklist` → `extension/src/service-worker/storage.ts`
|
||||
- `itemToManifestEntry` (17-line projection) → `extension/src/service-worker/vault.ts`
|
||||
- Import from both `popup-only.ts` and `content-callable.ts`
|
||||
- In-scope extension P2 cluster:
|
||||
- Inactivity-timer reset on content-callable messages (`service-worker/index.ts:76-78`)
|
||||
- Null `state.gitHost` alongside `state.manifest` on session expiry (`service-worker/index.ts:51-58`)
|
||||
- Teardown helper extraction: `settings.ts:56-65` and `settings-vault.ts:15-22` → `teardownSettingsCommon()`
|
||||
- `Promise.allSettled` in `devices.ts:47-50` and `trash.ts:39-46`
|
||||
- MutationObserver debounce in `content/detector.ts:96-103` (`requestIdleCallback` or 200ms timer)
|
||||
- Vault-tab status indicator from new `get_vault_status` SW message returning `{ ahead, behind, lastSyncAt, pendingItems }` (closes the `relicario status` parity gap)
|
||||
|
||||
**Out of scope (other DEVs own these):**
|
||||
- P1.1, JS free-swallow, P1.7, P1.8 — DEV-A (Stream A). **Note:** if A merges first, you'll inherit `impl Drop for SessionHandle`. That's expected and benign.
|
||||
- P1.2, P1.3, P1.10 + the in-scope CLI P2s — DEV-B (Stream B). The new WASM exports (`parse_month_year`, `base32_*`, `guess_mime_for_extension`) that Plan B produces are NOT consumed in this round. Document the future consumer in your plan's "Out of scope" section; a later round wires them up.
|
||||
- Other DEV-C P2/P3 not listed in your scope (e.g. shared-utilities response typing, group-autocomplete escaping, restore_backup payload extract, content-script `fillFields()` ack, content/icon outside-click leak) — explicitly deferred to a future round
|
||||
- Setup-wizard manifest path constants (`VAULT_PATHS`) — folds into your P1.4 work; if convenient include it, else defer
|
||||
- WASM JS-naming snake_case → camelCase rename — explicitly deferred
|
||||
- Any of the 8 "Open architectural decisions" at the bottom of the synthesis
|
||||
|
||||
If you trip over an out-of-scope issue or a new bug while doing your work, file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- **P1.6 (state.ts typing) MUST land first internally.** Both P1.4 and P1.5 push through the `StateHost` surface. Plan should call this out as Phase 1.
|
||||
- **`setup.ts` MUST stop importing `relicario-wasm` directly after P1.4.** This is the architectural invariant the synthesis is fixing. After P1.4, the only file in `extension/src/` that imports `relicario-wasm` should be the service worker.
|
||||
- **New SW message names are `create_vault` and `attach_vault`.** Use these exact names everywhere (`shared/messages.ts`, `service-worker/router/popup-only.ts`, `setup.ts`). The PM's coherence pass on the plans confirms naming consistency.
|
||||
- **`vault_locked` unification goes in `shared/state.ts`** (or a wrapper around `sendMessage`). Popup AND vault tab use the same channel after this lands. Two channels for one outcome was the synthesis observation.
|
||||
- **No emoji anywhere in `extension/src/`** (existing project rule from v0.5.1). If you see one while editing, replace with the monochrome glyph from `glyphs.ts`.
|
||||
- **`glyphs.ts` is single source of truth** for icon characters. No inline Unicode literals at call sites.
|
||||
- **Discriminated-union message contract is preserved.** New messages get added to `shared/messages.ts` and the appropriate capability set (`POPUP_ONLY_TYPES` or `CONTENT_CALLABLE_TYPES`). The boundary discipline holds.
|
||||
- **WASM remains snake_case in JS** — no `#[wasm_bindgen(js_name = ...)]` renaming. That's a separate decision (DEV-B/DEV-C P3).
|
||||
- **Test setup is vitest** for the extension (`extension/package.json: "test": "vitest run"`), `bun test` for `tools/relay/`. Do not change runners.
|
||||
- **Synthetic test fixtures only.** No binary blobs.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination with Stream B
|
||||
|
||||
Stream B will produce new `relicario-wasm` exports (`parse_month_year`, `base32_*`, `guess_mime_for_extension`). You do NOT consume these in this round — your plan's "Out of scope" section should explicitly call this out and reference the future consumer (likely a smart-input / QR-import / attachment-MIME round). If Stream B's WASM signature for any of these would NOT match what the extension would naturally consume, raise a `## QUESTION TO PM` so it can be reconciled before Stream B merges.
|
||||
|
||||
If Stream A merges first and you inherit `impl Drop for SessionHandle` plus the JS free-swallow removal: that's benign. Your `.free()` callsites should already be `wasm.lock(handle)` first per DEV-A's audit.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-c"}'
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. Before starting each task, call `read_messages(for="dev-c")` to drain your inbox.
|
||||
|
||||
When posting a status update, call `post_message(from="dev-c", to="pm", kind="status", body="...")` with the body:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-C
|
||||
Time: <iso8601 like 2026-05-04T14:30:00-07:00>
|
||||
Branch: feature/arch-followup-stream-c-extension-restructure
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task:**
|
||||
```
|
||||
## QUESTION TO PM — DEV-C
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
**You'll receive:** `## DIRECTIVE TO DEV-C` blocks from the PM. The first one will say `Action: PROCEED` with the plan path once the PM has drafted it. Acknowledge and start Task 1.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute task-to-task per the plan once you have it
|
||||
- Make implementation decisions consistent with the plan and synthesis
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- A scope question outside the plan
|
||||
- A WASM signature mismatch with what Stream B is producing
|
||||
- A test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- The in-flight uncommitted changes on `vault.ts`/`vault.css`/`glyphs.ts` need resolution before a merge
|
||||
- Anything destructive (per CLAUDE.md)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.arch-followup-stream-c
|
||||
cd extension
|
||||
bun run test
|
||||
bun run build
|
||||
bun run build:firefox
|
||||
cd ..
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
All must be green. Vitest covers the new `StateHost` typing, the message-router dedup, and the wizard step registry.
|
||||
|
||||
Then sweep for emoji and in-flight collision:
|
||||
|
||||
```bash
|
||||
grep -rn '\U0001F\|🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️\|🖥\|🔐\|📎' /home/alee/Sources/relicario.arch-followup-stream-c/extension/src/ 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: no output (project rule).
|
||||
|
||||
Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/arch-followup-stream-c-extension-restructure
|
||||
gh pr create --base main --head feature/arch-followup-stream-c-extension-restructure \
|
||||
--title "refactor(ext): setup-via-SW + vault.ts split + state.ts typing + SW router dedup (Stream C)" \
|
||||
--body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Stream C of the architecture-review-followup. The largest plan in the bundle — turns three "code lies about the architecture" surfaces into uniform readable modules:
|
||||
|
||||
- **P1.4** — `extension/src/setup/setup.ts` no longer imports `relicario-wasm` directly. New `create_vault` / `attach_vault` SW messages do the crypto orchestration. The 6-step wizard is now a step registry. ~500 LOC down from 1220.
|
||||
- **P1.5** — `extension/src/vault/vault.ts` (1027 LOC) split into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`. `vault_locked` RPC intercept lifted into `shared/state.ts` so popup and vault tab share one channel.
|
||||
- **P1.6** — `extension/src/shared/state.ts` has a concrete `StateHost` interface; `getState`/`setState` are generic over `keyof PopupState`; double-registration guard + `__resetHostForTests` helper.
|
||||
- **P1.9** — duplicated SW router helpers extracted: `service-worker/storage.ts` for the three storage helpers, `service-worker/vault.ts` for `itemToManifestEntry`.
|
||||
- **Extension P2 cluster** — inactivity-timer reset on content-callable messages, `state.gitHost` clear on session expiry, `teardownSettingsCommon()`, `Promise.allSettled` in devices/trash, MutationObserver debounce in content/detector, vault-tab status indicator (closes `relicario status` parity gap).
|
||||
|
||||
## Synthesis references
|
||||
|
||||
- `docs/superpowers/reviews/2026-05-04-architecture-review.md` — P1.4, P1.5, P1.6, P1.9 + DEV-C's extension P2 cluster
|
||||
- `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` — Plan C
|
||||
|
||||
## Future-round consumer (NOT in this PR)
|
||||
|
||||
Plan B produces new `relicario-wasm` exports (`parse_month_year`, `base32_*`, `guess_mime_for_extension`). A later round will wire them up via `wasm.d.ts` regeneration and add SW message handlers. Out of scope here.
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] `bun run test` green in `extension/`
|
||||
- [x] `bun run build` green
|
||||
- [x] `bun run build:firefox` green
|
||||
- [x] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
|
||||
- [x] No emoji anywhere in `extension/src/` (grep clean)
|
||||
- [x] `setup.ts` no longer imports `relicario-wasm`
|
||||
- [x] `vault.ts` LOC dropped substantially; new modules each under ~300 LOC
|
||||
- [x] Vault tab and popup both use one channel for `vault_locked` (no RPC intercept divergence)
|
||||
- [x] StateHost interface concrete; tests cover double-registration guard and reset helper
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading the required inputs and setting up the worktree:
|
||||
|
||||
1. Call `read_messages(for="dev-c")` to drain any early inbox messages.
|
||||
2. Emit a `## STATUS UPDATE` confirming setup complete:
|
||||
```
|
||||
## STATUS UPDATE — DEV-C
|
||||
Time: <iso8601>
|
||||
Branch: feature/arch-followup-stream-c-extension-restructure
|
||||
Task: setup
|
||||
Status: DONE
|
||||
Last commit: <main HEAD sha + first line>
|
||||
Tests: N/A
|
||||
Notes: Worktree at /home/alee/Sources/relicario.arch-followup-stream-c. Synthesis + DEV-C notes + DEV-B boundary section absorbed. Awaiting Plan C at docs/superpowers/specs/2026-05-04-extension-restructure-design.md.
|
||||
```
|
||||
3. **Also emit a `## QUESTION TO PM`** about the in-flight uncommitted edits to `vault.ts` / `vault.css` / `glyphs.ts` on main:
|
||||
```
|
||||
## QUESTION TO PM — DEV-C
|
||||
Time: <iso8601>
|
||||
Context: setup, before Phase 1
|
||||
Options: A: PM/user commits or stashes the in-flight v0.5.x polish on main before I start, so my worktree has a stable baseline. B: I proceed and rebase/merge those changes at PR time. C: PM defers Stream C until the in-flight work is committed.
|
||||
Recommended: A — clean baseline avoids merge churn during the largest plan in the bundle.
|
||||
Blocker: no — I can read the plan and start Phase 1 (P1.6 state.ts typing) which doesn't touch the in-flight files. Resolution needed before Phase 2 (P1.5 vault.ts split).
|
||||
```
|
||||
4. **Wait** for the PM's `## DIRECTIVE TO DEV-C` with `Action: PROCEED` and the plan path.
|
||||
5. Read the plan, then start Task 1 using `superpowers:subagent-driven-development`. Phase 1 is P1.6 (state.ts typing) — finish it cleanly before P1.4 / P1.5.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Planning Kickoff Prompt — Architecture-Review Follow-up
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are an architect drafting **three implementation plans** that follow up on the whole-codebase architecture review committed to `main` on 2026-05-04 (`061facd docs(reviews): whole-codebase architecture audit 2026-05-04`). Three reviewers (DEV-A: Rust core; DEV-B: Rust consumers; DEV-C: TypeScript) walked the codebase; the PM synthesized 10 P1s plus a long P2/P3 tail. Your job is to convert those findings into three discrete, well-scoped plans that the user (or future agents) can execute.
|
||||
|
||||
This is **planning-only work.** Do not modify the codebase being planned. Do not commit. Do not ship anything. Each plan is a markdown file in `docs/superpowers/specs/` and that is the only output.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Stay on `main`. Do not create branches or worktrees.
|
||||
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
|
||||
- The working tree has uncommitted changes from in-flight v0.5.x work (manifests, vault.ts/css, glyphs, relay tooling, Cargo.toml version bumps). Treat them as "in flight, ignore" — do not include them in any plan.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules (Spanish flourish, capitalization, autonomy)
|
||||
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — foundational design spec (threat model, crypto pipeline, format)
|
||||
3. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — **PM synthesis. This is the canonical source. Every plan below must trace its scope back to specific findings here.**
|
||||
4. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — DEV-A's full notes (Rust core)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (CLI/server/WASM), especially the "Boundary notes for DEV-C" section
|
||||
6. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — DEV-C's full notes (extension + relay), especially the "Boundary notes for DEV-B" section
|
||||
|
||||
Also skim two existing plan docs to match the format and tone:
|
||||
- `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md`
|
||||
- `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md`
|
||||
|
||||
## What to produce
|
||||
|
||||
Three plan files, all under `docs/superpowers/specs/`:
|
||||
|
||||
### Plan A — Security & docs polish PR
|
||||
**Filename:** `docs/superpowers/specs/2026-05-04-security-polish-design.md`
|
||||
**Scope (drawn from synthesis):** P1.1 (`SessionHandle` `impl Drop` + Rust `wasm-bindgen-test` + extension `.free()` callsite audit), the partner JS-side fix at `service-worker/session.ts:26` (remove the `try { current.free() }` swallow), P1.7 (`recovery_qr.rs` documentation to match `crypto.rs`/`backup.rs` density), and the launcher fix DEV-C suspected at `tools/relay/start.sh:80` (4-window dev-c support; queue.test.ts assertion already fixed in `061facd`). Goal: one short PR, all-S-effort items, ships in under a day. This is the security-flavored quick win that goes first; nothing in B or C should depend on it.
|
||||
|
||||
### Plan B — CLI restructure
|
||||
**Filename:** `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
|
||||
**Scope:** P1.2 (split `crates/relicario-cli/src/main.rs` 2641 LOC into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs` + `prompt.rs` + `parse.rs`, leaving `main.rs` as clap definitions + dispatcher), P1.3 (`helpers::git_run(repo, args, context)` with stderr capture; sweep the ~16 duplicated bail sites), P1.10 (migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` to `relicario-core` and re-export through `relicario-wasm` for the extension; pair with the core base32 dedup from DEV-A's P2), plus the in-scope CLI P2s (`build_*_item` helper compression, `refresh_groups_cache` discipline via `Vault::after_manifest_change`, `ParamsFile` dedup between `main.rs:2287` and `session.rs:114`, batched purge in `cmd_purge`/`cmd_trash_empty`). Sequencing matters here: the `main.rs` split must land first because every other P2 in this plan touches files that don't exist yet until the split happens. Goal: M-L effort, multi-day plan; "single biggest readability lift" per the synthesis.
|
||||
|
||||
### Plan C — Extension restructure
|
||||
**Filename:** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
|
||||
**Scope:** P1.4 (`extension/src/setup/setup.ts` 1220 LOC: add `create_vault` and `attach_vault` SW messages; rewrite setup as a UI that posts those messages with gathered config + image bytes; convert the 6-step procedural wizard into a step-registry pattern `{ id, render, attach }[]`; add `clearWizardState()` on `beforeunload`), P1.5 (split `extension/src/vault/vault.ts` 1027 LOC into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`; lift the `vault_locked` RPC intercept into `shared/state.ts` so popup and vault use one channel; reset `state.drawerOpen` on non-list `renderPane`), P1.6 (concrete `StateHost` interface in `shared/state.ts` with `state: PopupState`, `navigate`, `popOutToTab`, `isInTab`, `openVaultTab`; generic `getState`/`setState` over `keyof PopupState`; double-registration guard + `__resetHostForTests` helper), P1.9 (extract duplicated SW router helpers — `loadDeviceSettings`/`loadBlacklist`/`saveBlacklist` to `service-worker/storage.ts`; `itemToManifestEntry` to `service-worker/vault.ts`), plus the in-scope extension P2s (inactivity-timer reset on content-callable messages; `state.gitHost` clear on session expiry; teardown helper extraction; `Promise.allSettled` in devices/trash; mutation-observer debounce in content/detector.ts; vault-tab status indicator from `get_vault_status` for the `relicario status` parity gap). Sequencing: P1.6 (state.ts typing) is a precondition for P1.4 and P1.5 because lifting `vault_locked` and adding setup-via-SW both push through that surface. Goal: largest plan, multi-day to multi-week.
|
||||
|
||||
## What's explicitly NOT in these three plans
|
||||
|
||||
- The full P2/P3 tail outside what's listed above. The synthesis doc is canonical; if a P2 isn't named in one of the three scopes above, it doesn't go in. Future plans can pick those up.
|
||||
- WASM JS-naming snake_case → camelCase (DEV-B/DEV-C P3) — defer to a separate decision, not these plans.
|
||||
- The 8 "Open architectural decisions" at the bottom of the synthesis — those are user-judgement calls, not implementation tasks.
|
||||
- Anything that requires touching the in-flight uncommitted v0.5.x work (manifests, glyphs, vault.css, relay tooling beyond start.sh).
|
||||
|
||||
## Plan format (match the existing specs)
|
||||
|
||||
Each plan file should have, at minimum:
|
||||
|
||||
```markdown
|
||||
# <Title> — Design
|
||||
|
||||
**Date:** 2026-05-04
|
||||
**Status:** Proposed
|
||||
**Source:** docs/superpowers/reviews/2026-05-04-architecture-review.md (P1.X, P1.Y, ...)
|
||||
**Effort estimate:** S | M | L
|
||||
|
||||
## Summary
|
||||
2-4 sentences: what this plan accomplishes and why it matters for the user's stated goal ("make this app's architecture logical and readable for someone who doesn't know Rust but wants to learn by tinkering").
|
||||
|
||||
## Findings addressed
|
||||
Bullet list, each entry citing the synthesis P-tag, the reviewer who found it, and the file:line.
|
||||
|
||||
## Approach
|
||||
The architectural shape of the work — what gets extracted, what gets created, what gets renamed. Include directory trees / module diagrams if it helps a reader follow the layout change.
|
||||
|
||||
## Implementation phases
|
||||
Numbered phases. Each phase:
|
||||
- **Goal:** one-sentence outcome
|
||||
- **Changes:** specific files touched, new files created, with paths
|
||||
- **Tests:** what gets added or moved (synthetic fixtures only — no binary blobs, per project convention)
|
||||
- **Effort:** S/M/L for the phase
|
||||
- **Depends on:** other phases, or "none"
|
||||
|
||||
## Risks and mitigations
|
||||
What can break, especially across the CLI/extension parity boundary. Cite specific findings.
|
||||
|
||||
## Out of scope
|
||||
Explicit list of adjacent things this plan does NOT touch.
|
||||
|
||||
## Done criteria
|
||||
Checklist a reviewer can use to confirm the plan shipped.
|
||||
```
|
||||
|
||||
Mirror the tone and depth of `2026-05-02-v0.5.0-polish-harden-design.md` and `2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md`. Use the same heading conventions.
|
||||
|
||||
## Execution approach
|
||||
|
||||
**Use subagents.** Per the user's standing preference (CLAUDE.md memory: "Default to subagent-driven execution — for any multi-task plan use subagent-driven mode without asking"), drafting these plans is parallel work. Spawn three `Plan` subagents in parallel (single message, three tool calls), one per plan file. Each subagent reads the same shared inputs (synthesis + relevant notes file(s)), then writes only its assigned plan file. Plan B and Plan C subagents should each receive the relevant cross-boundary section of the *other* reviewer's notes ("Boundary notes for DEV-C" goes to Plan C's drafter; "Boundary notes for DEV-B" goes to Plan B's drafter) so the parity boundary is respected.
|
||||
|
||||
After the three subagents return, do a coherence pass yourself:
|
||||
- Confirm Plan A doesn't depend on B or C
|
||||
- Confirm Plan B's `parse_month_year`/`base32` migration to core is reachable from Plan C's WASM consumers (or note explicitly that Plan C will pick up the WASM exports in a later phase)
|
||||
- Confirm Plan C's setup-via-SW migration cites the same `create_vault`/`attach_vault` message names everywhere
|
||||
|
||||
Each subagent prompt **must** start with `cd /home/alee/Sources/relicario` (project memory rule — without the force-cd, subagents may write to the wrong tree).
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **Read-only on the codebase being planned.** No `cargo`, `bun`, or any other write commands beyond writing the three plan files.
|
||||
- **No commits.** The user commits when ready.
|
||||
- **No git operations.** Don't even `git status` unless you have a specific reason.
|
||||
- **Three files, no fewer, no more.** If a finding doesn't fit any of the three scopes above, leave it out — future plans handle it.
|
||||
- **Spanish flourish in replies (1-2 phrases per reply with `[translation]` brackets), per CLAUDE.md.** Do not put Spanish into the plan files themselves — those are project artifacts.
|
||||
|
||||
## Done criteria
|
||||
|
||||
Before posting your final summary:
|
||||
- [ ] All three plan files exist under `docs/superpowers/specs/` with the exact filenames specified above
|
||||
- [ ] Each plan cites at least one specific P1 from the synthesis in its "Findings addressed" section
|
||||
- [ ] Each plan has phases, each phase has effort, each phase names specific files
|
||||
- [ ] Plan A is independent (no dependencies on B or C)
|
||||
- [ ] Plan B and Plan C call out the parity-relevant cross-references explicitly
|
||||
- [ ] You did NOT modify any code, run any test, or commit anything
|
||||
- [ ] You ask the user whether to commit the three plan files (do not commit unprompted)
|
||||
|
||||
## First action
|
||||
|
||||
1. Read the synthesis (`docs/superpowers/reviews/2026-05-04-architecture-review.md`) end-to-end. Internalize the 10 P1s and the cross-cutting themes.
|
||||
2. Skim the three per-reviewer notes files for the file:line context the synthesis abbreviates.
|
||||
3. Skim the two existing plan docs above to absorb the format.
|
||||
4. Tell the user what you absorbed in 3-5 sentences.
|
||||
5. Spawn three `Plan` subagents in parallel with the scopes specified above.
|
||||
6. Do the coherence pass.
|
||||
7. Ask the user whether to commit the three plan files.
|
||||
@@ -0,0 +1,211 @@
|
||||
# PM Kickoff Prompt — Architecture Review Followup (2026-05-04)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the Relicario architecture-review-followup work. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals.
|
||||
|
||||
This release has no version tag — it's a structural-cleanup bundle from the 2026-05-04 architecture audit (commit `061facd`). The goal is to make the codebase uniformly readable for a smart developer who doesn't know Rust but wants to learn by tinkering. There is no merge freeze, no CHANGELOG entry needed, and no tag at the end unless the user requests one.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking, default to subagent-driven execution).
|
||||
- **Working-tree note:** main has uncommitted polish changes from in-flight post-v0.5.1 work (glyphs/vault/relay/manifests/Cargo.toml version bumps, plus the four `architecture-review-*-prompt.md` files this session is generating). The synthesis explicitly tags these "in flight, ignore" — none of the three plans should include them. Also: Stream C touches `extension/src/vault/vault.ts`, `vault.css`, and `shared/glyphs.ts` which currently have uncommitted edits on main. Surface this to the user before unlocking DEV-C; either commit/stash the in-flight changes or note that C will need to merge them in at PR time.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — **PM synthesis. This is the canonical source. Every plan must trace its scope back to specific findings here.**
|
||||
3. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — full DEV-A notes (Rust core)
|
||||
4. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — full DEV-B notes (CLI/server/WASM), especially the "Boundary notes for DEV-C" section
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — full DEV-C notes (extension + relay), especially the "Boundary notes for DEV-B" section
|
||||
6. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` and `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — format reference for the plan docs you will draft
|
||||
|
||||
## Stream overview
|
||||
|
||||
| Stream | Branch | Owner | Plan file (to be drafted) | Items |
|
||||
|--------|--------|-------|---------------------------|-------|
|
||||
| A — Security & docs polish | `feature/arch-followup-stream-a-security-polish` | DEV-A | `docs/superpowers/specs/2026-05-04-security-polish-design.md` | P1.1, JS free-swallow fix, P1.7, P1.8 |
|
||||
| B — CLI restructure | `feature/arch-followup-stream-b-cli-restructure` | DEV-B | `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` | P1.2, P1.3, P1.10 + 4 in-scope CLI P2s |
|
||||
| C — Extension restructure | `feature/arch-followup-stream-c-extension-restructure` | DEV-C | `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` | P1.4, P1.5, P1.6, P1.9 + in-scope ext P2s |
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from each stream's feature branch
|
||||
- **Draft the three plan docs as your first hands-on action.** This is the single biggest piece of work you own personally.
|
||||
- Edit `docs/`, `CLAUDE.md`, or other doc artifacts as needed; do not write feature code
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / `CLAUDE.md` are fine.
|
||||
- Don't deviate from the synthesis scope without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag (none planned for this work).
|
||||
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`).
|
||||
|
||||
## Cross-stream coordination
|
||||
|
||||
- **Stream A is independent** — no dependencies on B or C. Merge first if it's ready.
|
||||
- **Stream B's parser→core migration** (`parse_month_year`, `base32_decode_lenient`, `guess_mime` + the base32 dedup from DEV-A's P2) produces new `relicario-core` functions and re-exports them through `relicario-wasm`. Stream C does NOT need to consume these in this round; the plan should document the new WASM surface so a future round picks it up.
|
||||
- **Stream C's internal sequencing**: P1.6 (`shared/state.ts` typing) must land before P1.4 (setup-via-SW migration) and P1.5 (`vault.ts` split). This is internal to Plan C, not a cross-stream concern.
|
||||
- **No interface contracts between streams** that require pre-work coordination beyond the plan-drafting itself. Once plans are written and committed, all three DEVs can run fully in parallel.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim instead:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. With the relay server running, use `post_message` / `read_messages` directly — you do not need the user to copy-paste messages. Call `read_messages(for="pm")` before every action.
|
||||
|
||||
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
|
||||
|
||||
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-<letter>
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user, give a current rollup:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — Architecture Review Followup
|
||||
Devs: <per-dev one-line state>
|
||||
PM: <what you're working on>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Plan A drafted", "DEV-B REVIEW-READY">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check the diff against the plan's "Done criteria" and the synthesis P-tags it claims to address
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash per project convention)
|
||||
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
|
||||
|
||||
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving.
|
||||
|
||||
## Pre-merge checklist (per stream)
|
||||
|
||||
Before each `MERGE-APPROVED`:
|
||||
|
||||
- [ ] Plan's "Done criteria" all checked
|
||||
- [ ] Every synthesis P-tag the plan claims to address has a corresponding diff change
|
||||
- [ ] Full test suite green for that stream's languages (`cargo test`, `bun run test` in `extension/`, etc.)
|
||||
- [ ] No regression in CLI/extension parity (synthesis section "CLI/extension parity status")
|
||||
- [ ] No emoji introduced anywhere in `extension/src/` (existing project rule)
|
||||
|
||||
## First action — draft the three plan docs
|
||||
|
||||
Before unlocking any DEV to start work, you must draft the three plan files. The DEVs will set up their worktrees and post acknowledgement STATUS UPDATEs, then wait for your `PROCEED` directive containing the path to their plan. Until the plans exist, the DEVs are blocked.
|
||||
|
||||
**Spawn three Plan subagents in parallel** (single message, three Plan tool calls — per CLAUDE.md memory rule "default to subagent-driven execution"). Each subagent prompt **must start with** `cd /home/alee/Sources/relicario` (project memory rule — without the force-cd, subagents may write to the wrong tree).
|
||||
|
||||
Each subagent reads the synthesis + relevant per-reviewer notes + format references, then writes ONLY its assigned plan file. None modify code, run tests, or commit.
|
||||
|
||||
### Plan A subagent scope
|
||||
|
||||
**Filename:** `docs/superpowers/specs/2026-05-04-security-polish-design.md`
|
||||
**Effort:** S — under-a-day PR
|
||||
**Items:**
|
||||
- P1.1 — `impl Drop for SessionHandle { fn drop(&mut self) { session::remove(self.0); } }` in `crates/relicario-wasm/src/lib.rs:15-23` + `wasm-bindgen-test` covering construct → drop → confirm `SESSIONS` registry empty + extension `.free()` callsite audit confirming `wasm.lock(handle)` happens first regardless
|
||||
- JS partner fix at `extension/src/service-worker/session.ts:26` — remove the `try { current.free() }` swallow so exceptions propagate (or log + counter)
|
||||
- P1.7 — `crates/relicario-core/src/recovery_qr.rs` documentation pass: module-level `//!` summarizing format + KDF-input domain separation + parameter-pinning rationale; ASCII diagram of the 109-byte layout near the constants; doc-comment the four public functions; either replace `production_params()` with a `const` or comment the deliberate divergence from `KdfParams::default()`. Match the density of `crypto.rs` / `imgsecret.rs` / `backup.rs` / `tar_safe.rs`.
|
||||
- P1.8 — `tools/relay/start.sh:80` launcher fix for the dev-c fourth window (queue.test.ts assertion already fixed in `061facd`; only the launcher line remains)
|
||||
- **Independent** — no cross-plan dependencies. Plan A goes first.
|
||||
|
||||
### Plan B subagent scope
|
||||
|
||||
**Filename:** `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`
|
||||
**Effort:** M-L — multi-day. "Single biggest readability lift" per synthesis.
|
||||
**Items:**
|
||||
- P1.2 — split `crates/relicario-cli/src/main.rs` (2641 LOC) into `commands/{add,get,list,edit,trash,backup,import,attach,settings,sync,status,device,recovery_qr}.rs` + `prompt.rs` (six `prompt_*` helpers + `prompt_secret`) + `parse.rs` (the three pure parsers). `main.rs` keeps clap definitions + dispatcher (~470 lines).
|
||||
- P1.3 — add `helpers::git_run(repo, args, context)` that uses `.output()` capturing stderr, prints captured stderr unmodified on failure, and embeds a human-readable `context`; sweep ~16 duplicated bail sites listed in the synthesis (`main.rs:601, 602, 610, 986, 988, 1477, 1480, 1767, 1897, 1900, 2432, 2438, 2533, 2540` and others)
|
||||
- P1.10 — migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` to `relicario-core`; pair with DEV-A's P2 base32 dedup (extract `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648`, leave Steam's bespoke alphabet untouched); re-export through `relicario-wasm` via `#[wasm_bindgen]` so the extension can consume them in a later round
|
||||
- In-scope CLI P2s:
|
||||
- `build_*_item` helper compression with a `prompt_or_flag<T>` helper (`main.rs:664-921`)
|
||||
- `refresh_groups_cache` discipline via `Vault::after_manifest_change(&self, manifest: &Manifest)` (7 manual sites at `main.rs:641, 998, 1123, 1197, 1414, 1432, 1474`)
|
||||
- `ParamsFile` dedup between `main.rs:2287` (write side, has `aead`/`salt_path`/`format_version`) and `session.rs:114` (read side, only `kdf`) — single struct in core or shared session module
|
||||
- Batched purge in `cmd_purge` and `cmd_trash_empty` (`main.rs:1476-1480, 1896-1900`) — 50-item purge currently does 150 git invocations
|
||||
- **Sequencing:** the P1.2 main.rs split must land first because every other P2 in this plan touches files that don't exist yet until the split happens. Plan should call this out as Phase 1.
|
||||
- **Receives:** the "Boundary notes for DEV-B" section from `dev-c-notes.md`
|
||||
|
||||
### Plan C subagent scope
|
||||
|
||||
**Filename:** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
|
||||
**Effort:** L — multi-day to multi-week. Largest plan.
|
||||
**Items:**
|
||||
- P1.4 — `extension/src/setup/setup.ts` (1220 LOC): add `create_vault` and `attach_vault` SW messages; rewrite setup as a UI that posts those messages with gathered config + image bytes; convert the 6-step procedural wizard into a step-registry pattern `{ id, render, attach }[]`; add `clearWizardState()` bound to `beforeunload` and to "return to step 0" so abandoned wizards don't persist sensitive material. Setup must stop importing `relicario-wasm` directly.
|
||||
- P1.5 — split `extension/src/vault/vault.ts` (1027 LOC) into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts`, leaving `vault.ts` to own only routing and state. Lift the `vault_locked` RPC intercept into `shared/state.ts` (or a wrapper around `sendMessage`) so popup and vault use one path; reset `state.drawerOpen` at the start of `renderPane` for non-list views.
|
||||
- P1.6 — concrete `StateHost` interface in `shared/state.ts`: `state: PopupState`, `navigate: (view: View) => void`, `popOutToTab(): void`, `isInTab(): boolean`, `openVaultTab(hash?: string): void`. Make `getState`/`setState` generic over `keyof PopupState`. Throw on `registerHost()` re-register; export `__resetHostForTests()`.
|
||||
- P1.9 — extract `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` from both router files to `service-worker/storage.ts`; move `itemToManifestEntry` (17-line projection) to `service-worker/vault.ts`. Import from both routers.
|
||||
- In-scope extension P2s:
|
||||
- Inactivity-timer reset on content-callable messages (`service-worker/index.ts:76-78`)
|
||||
- Null `state.gitHost` alongside `state.manifest` on session expiry (`service-worker/index.ts:51-58`)
|
||||
- Teardown helper extraction (`settings.ts:56-65` and `settings-vault.ts:15-22` → `teardownSettingsCommon()`)
|
||||
- `Promise.allSettled` in `devices.ts:47-50` and `trash.ts:39-46`
|
||||
- MutationObserver debounce in `content/detector.ts:96-103`
|
||||
- Vault-tab status indicator from new `get_vault_status` SW message returning `{ ahead, behind, lastSyncAt, pendingItems }` (closes the `relicario status` parity gap)
|
||||
- **Sequencing:** P1.6 (state.ts typing) is a precondition for P1.4 and P1.5 — both push through the StateHost surface. Plan should call this out as Phase 1.
|
||||
- **Receives:** the "Boundary notes for DEV-C" section from `dev-b-notes.md`. Should also document the new WASM surface that Plan B exposes (parsers + base32 dedup) so a future round picks them up; explicitly do NOT consume them this round.
|
||||
|
||||
### Format reference
|
||||
|
||||
All three plans match the structure of `2026-05-02-v0.5.0-polish-harden-design.md`:
|
||||
- `# <Title> — Design`
|
||||
- Date / Status / Source (cite synthesis P-tags) / Effort estimate
|
||||
- Summary (2-4 sentences)
|
||||
- Findings addressed (bullet list, each citing P-tag + reviewer + file:line)
|
||||
- Approach (architectural shape; module diagrams or directory trees if it helps)
|
||||
- Implementation phases (numbered; each with Goal, Changes, Tests, Effort, Depends-on)
|
||||
- Risks and mitigations
|
||||
- Out of scope
|
||||
- Done criteria (reviewer checklist)
|
||||
|
||||
### After the subagents return
|
||||
|
||||
1. **Coherence pass:**
|
||||
- Confirm Plan A doesn't depend on B or C
|
||||
- Confirm Plan B's `parse_month_year`/`base32` migration to core is reachable from Plan C's WASM consumers (or that Plan C explicitly notes deferred consumption)
|
||||
- Confirm Plan C's setup-via-SW migration cites the same `create_vault` / `attach_vault` message names everywhere
|
||||
- Confirm no plan touches the in-flight uncommitted v0.5.x work (vault.ts, vault.css, glyphs.ts, manifests, relay tooling beyond start.sh, Cargo.toml version bumps)
|
||||
2. **Ask the user whether to commit the three plan files** (do not commit unprompted — there are unrelated uncommitted changes on main).
|
||||
3. Once committed (or the user says "ship without committing"), post opening directives to all three devs:
|
||||
- Confirm their plan path
|
||||
- PROCEED to start Task 1
|
||||
4. Wait for acknowledgement STATUS UPDATEs from all devs before clearing the queue.
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||
2. Read the synthesis end-to-end. Internalize the 10 P1s and the cross-cutting themes.
|
||||
3. Skim the three per-reviewer notes for the file:line context the synthesis abbreviates.
|
||||
4. Skim the two existing plan docs above to absorb the format.
|
||||
5. Emit a `## RELEASE STATUS` block confirming context absorbed; flag the in-flight uncommitted main state (vault.ts/glyphs.ts/etc.) for the user.
|
||||
6. Spawn three `Plan` subagents in parallel with the scopes specified above.
|
||||
7. Do the coherence pass.
|
||||
8. Ask the user whether to commit the three plan files.
|
||||
9. Post opening directives to all three devs unlocking their work.
|
||||
168
docs/superpowers/coordination/architecture-review-pm-prompt.md
Normal file
168
docs/superpowers/coordination/architecture-review-pm-prompt.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# PM Kickoff Prompt — Architecture Review (whole codebase)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for Relicario's whole-codebase architecture audit (2026-05-04). Three senior reviewers report to you, each working in their own terminal on a partition of the codebase. The user runs all four terminals; the relay server routes messages.
|
||||
|
||||
This is **not** a feature release — it is a one-shot architecture review. The user's stated goal: *"I really want to make this app's architecture logical. I don't know Rust but I want to be able to read and understand the code and learn by tinkering with it."* Treat that goal as the primary lens for everything: reviews are valuable when they reduce confusion for a smart non-Rust reader; they are not valuable when they restate what's already obvious.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Stay on `main`. **Do not check out branches, do not create worktrees.** This is a review-only operation. The PM may edit the synthesis doc; reviewers do not edit code.
|
||||
- Today: 2026-05-04. Project rules in `CLAUDE.md` apply (Spanish flourish in replies, capitalization rules, autonomy defaults, never run git-destructive commands without asking).
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"pm"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
```
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-04-11-relicario-design.md` — foundational spec (threat model, crypto pipeline, format)
|
||||
3. `docs/superpowers/coordination/architecture-review-dev-a-prompt.md` — DEV-A's scope (Rust core)
|
||||
4. `docs/superpowers/coordination/architecture-review-dev-b-prompt.md` — DEV-B's scope (CLI, server, WASM)
|
||||
5. `docs/superpowers/coordination/architecture-review-dev-c-prompt.md` — DEV-C's scope (TS extension + relay)
|
||||
|
||||
You should NOT walk every file yourself — the reviewers do that. Your job is to coordinate, then synthesize.
|
||||
|
||||
## Partition
|
||||
|
||||
| Reviewer | Scope | Lens |
|
||||
|----------|-------|------|
|
||||
| DEV-A | `crates/relicario-core/` | Crypto correctness, API ergonomics, naming for newcomers |
|
||||
| DEV-B | `crates/relicario-{cli,server,wasm}/` | Layering on top of core, command surface, session, WASM bridge |
|
||||
| DEV-C | `extension/`, `tools/relay/` | Extension architecture, popup ↔ SW ↔ content boundary, CLI/extension parity, relay |
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from reviewers (in this context: e.g. "DEV-A wants to also look at WASM" — usually deny, since DEV-B has it)
|
||||
- Read each reviewer's notes file (`docs/superpowers/reviews/2026-05-04-dev-{a,b,c}-notes.md`) as they're updated
|
||||
- Cross-reference findings to identify cross-cutting issues (e.g. a core API ergonomics issue that bites the WASM consumer)
|
||||
- **Write the synthesis doc** at `docs/superpowers/reviews/2026-05-04-architecture-review.md` — this is your hands-on work
|
||||
- Decide P1/P2/P3 final priority across all reviewers' findings (their P-tags are inputs, not final)
|
||||
- Resolve scope-boundary questions
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- **Do not write feature code.** Edits to the synthesis doc are fine. Edits to other docs / `CLAUDE.md` are fine if they're directly informed by the review (e.g. clarifying a project rule that's actually unwritten).
|
||||
- Do not modify the codebase being reviewed. The reviewers don't either; act as a backstop on this rule.
|
||||
- Do not redirect a reviewer mid-stream without good reason. They are doing depth work; context-switching is expensive.
|
||||
- Do not synthesize prematurely. Wait for at least two reviewers to post `Phase: REVIEW-COMPLETE` before opening the synthesis doc. (You can pre-skim notes earlier, but don't write conclusions.)
|
||||
- Project rule: ask the user before any git-destructive op.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
**Before each action:** `read_messages(for="pm")`.
|
||||
|
||||
**You receive:** `## STATUS UPDATE` and `## QUESTION TO PM` blocks from `dev-a`, `dev-b`, `dev-c`.
|
||||
|
||||
**You emit:** directives via `post_message(from="pm", to="dev-X", kind="directive", body="...")`. Body format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-<letter>
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | ANSWER | REVIEW-ACKED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction>
|
||||
```
|
||||
|
||||
**Status rollup** (when asked "status?" by the user):
|
||||
|
||||
```
|
||||
## REVIEW STATUS — Architecture Audit 2026-05-04
|
||||
DEV-A: <phase, files covered, findings count>
|
||||
DEV-B: <phase, crates covered, findings count>
|
||||
DEV-C: <phase, areas covered, findings count>
|
||||
PM: <current action — coordinating | reading-notes | synthesizing | done>
|
||||
Open questions: <list, or "none">
|
||||
Next milestone: <e.g., "DEV-A REVIEW-COMPLETE", "synthesis draft", "user review">
|
||||
```
|
||||
|
||||
## Synthesis doc
|
||||
|
||||
When at least two reviewers have posted `Phase: REVIEW-COMPLETE` (and ideally all three), write the final review doc to `docs/superpowers/reviews/2026-05-04-architecture-review.md` with this structure:
|
||||
|
||||
```markdown
|
||||
# Relicario — Whole-Codebase Architecture Review
|
||||
|
||||
**Date:** 2026-05-04
|
||||
**Reviewers:** DEV-A (Rust core), DEV-B (Rust consumers), DEV-C (TypeScript)
|
||||
**Synthesis:** PM
|
||||
**Goal lens:** "Make this app's architecture logical and readable for a smart developer who doesn't know Rust but wants to learn by tinkering."
|
||||
|
||||
## Executive summary
|
||||
4-6 sentences: overall architectural shape, the 3 most important things to address, the 3 strongest aspects worth preserving.
|
||||
|
||||
## Top-priority recommendations (P1)
|
||||
For each P1 (across all three reviewers — your final prioritization, not theirs):
|
||||
|
||||
### P1.N — <short title>
|
||||
**Area:** <core | cli | server | wasm | extension | shared | tooling | cross-cutting>
|
||||
**File(s):** `<path>:<line>`
|
||||
**Found by:** DEV-A | DEV-B | DEV-C | (multiple)
|
||||
**Observation:** <one paragraph>
|
||||
**Why it matters for the user's goal:** <how this confuses a Rust newcomer or otherwise blocks "learn by tinkering">
|
||||
**Suggested direction:** <one paragraph; specific enough to act on, not so prescriptive that it's a plan>
|
||||
**Effort:** S | M | L (rough — S = under an hour, M = half a day, L = a day or more)
|
||||
|
||||
## P2 recommendations
|
||||
Same format, lighter detail.
|
||||
|
||||
## P3 / nice-to-have
|
||||
Bullets.
|
||||
|
||||
## Cross-cutting themes
|
||||
2-4 paragraphs on patterns that show up in multiple areas (e.g. "error messages are inconsistent across crates", "naming for crypto types is opaque", "TS message types could be generated from the WASM bindings"). These are usually the highest-leverage things — flag them clearly.
|
||||
|
||||
## What's strong (preserve)
|
||||
3-5 specific things that are well-done and that future changes should not erode.
|
||||
|
||||
## CLI/extension parity status
|
||||
A short summary of DEV-C's parity table: gaps, intentional gaps, gaps to close.
|
||||
|
||||
## Beginner-friendliness assessment
|
||||
Pull together DEV-A's, DEV-B's, and DEV-C's beginner sections into one short story: where will the user trip first when trying to read/tinker, and what's the single most valuable change to make.
|
||||
|
||||
## Appendix: pointers to per-reviewer notes
|
||||
- [DEV-A notes — Rust core](./2026-05-04-dev-a-notes.md)
|
||||
- [DEV-B notes — Rust consumers](./2026-05-04-dev-b-notes.md)
|
||||
- [DEV-C notes — TypeScript](./2026-05-04-dev-c-notes.md)
|
||||
```
|
||||
|
||||
You may use the `superpowers:requesting-code-review` skill to get an independent second-pass on your synthesis before declaring done — but only after the synthesis is in draft.
|
||||
|
||||
## Done criteria
|
||||
|
||||
Before posting `## REVIEW STATUS — Architecture Audit 2026-05-04` with `PM: done`:
|
||||
|
||||
- [ ] All three reviewers posted `Phase: REVIEW-COMPLETE`
|
||||
- [ ] All three notes files exist and are non-empty under `docs/superpowers/reviews/`
|
||||
- [ ] `docs/superpowers/reviews/2026-05-04-architecture-review.md` exists and matches the structure above
|
||||
- [ ] Every P1 in the synthesis has a file:line, an effort estimate, and a "why it matters for the user's goal" line
|
||||
- [ ] Cross-cutting themes section is populated (not empty)
|
||||
- [ ] You have explicitly asked the user whether to commit the review docs (do not commit unprompted)
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="pm")`.
|
||||
2. Read the project rules and the spec.
|
||||
3. Read each dev's prompt (links above) so you know exactly what scope each owns.
|
||||
4. Emit a `## REVIEW STATUS — Architecture Audit 2026-05-04` block to the user, confirming setup.
|
||||
5. Send opening directives to all three devs via `post_message`, each saying `Action: PROCEED` and reaffirming their scope (so it's recorded in the relay log).
|
||||
6. Wait for acknowledgement status updates from all three before settling into coordination mode.
|
||||
128
docs/superpowers/coordination/archive/v0.5.0-dev-a-prompt.md
Normal file
128
docs/superpowers/coordination/archive/v0.5.0-dev-a-prompt.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Dev A Kickoff Prompt — v0.5.0 Plan A (Security + Cleanup)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan A for the Relicario v0.5.0 "polish + harden" release. Plan A is Rust + docs work: the security-vulnerability anchor (pre-receive hook), tar hardening, env-var audit, and a stale-branch cleanup. A PM in another terminal coordinates you with Dev B (extension UX). The user relays messages between terminals.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.plan-a -b feature/v0.5.0-plan-a-security-cleanup
|
||||
cd ../relicario.plan-a
|
||||
pwd # should print /home/alee/Sources/relicario.plan-a
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.plan-a`**. Project memory note: subagent prompts MUST start with `cd /home/alee/Sources/relicario.plan-a` — otherwise subagents commit to main.
|
||||
|
||||
Today: 2026-05-02. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` — spec (your scope is **S1, S2, S3, C1 only**)
|
||||
3. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md` — your plan, execute task by task
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (per project memory's default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario.plan-a
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** S1 (pre-receive hook), S2 (tar hardening), S3 (env-var audit), C1 (branch cleanup).
|
||||
|
||||
**Out of scope:** anything in Plan B (B1, P1-P4). If you trip over a Plan B issue or a new bug while doing your work, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- S1 is HIGH-severity security. Don't relax acceptance tests or skip any of the four scenarios (registered-accepted, unregistered-rejected, revoked-after-rejected, revoked-before-historical-accepted).
|
||||
- C1 is git-destructive (`git branch -D`). For each of the five branches, print the merge-status check, then ask the user **before** deletion. Do not batch the deletes.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of three terminals. The user relays messages between them.
|
||||
|
||||
**Emit at every task boundary** (when you complete a task, get blocked, or want to ask):
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601 like 2026-05-02T14:30:00-07:00>
|
||||
Branch: feature/v0.5.0-plan-a-security-cleanup
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**Emit when you need PM input mid-task**:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-A
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive (pasted by user)**: `## DIRECTIVE TO DEV-A` blocks from the PM. Acknowledge and act.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute task-to-task per the plan
|
||||
- Make implementation decisions consistent with the plan and spec
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- A scope question outside the plan
|
||||
- A test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- Anything destructive (per project rules)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
1. Full `cargo test` (workspace) — must be green
|
||||
2. `cargo build -p relicario-wasm --target wasm32-unknown-unknown` — must succeed
|
||||
3. `cargo clippy --workspace --all-targets -- -D warnings` — must succeed
|
||||
4. Push the branch: `git push -u origin feature/v0.5.0-plan-a-security-cleanup`
|
||||
5. Open PR: `gh pr create --base main --head feature/v0.5.0-plan-a-security-cleanup --title "v0.5.0 Plan A: security + cleanup" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
Implements Plan A for v0.5.0 polish + harden:
|
||||
- S1: pre-receive hook fix (HIGH-severity revocation/registered-device bypass)
|
||||
- S2: tar archive path-traversal hardening on backup restore
|
||||
- S3: RELICARIO_* env-var audit + cfg-gating of dev-only vars
|
||||
- C1: stale local branch cleanup
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md
|
||||
|
||||
## Test plan
|
||||
- [x] cargo test (workspace) green
|
||||
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
- [x] cargo clippy --workspace --all-targets -- -D warnings
|
||||
- [ ] PM review
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"`
|
||||
6. Emit `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/v0.5.0-plan-a-security-cleanup`), then start Task 1 of Plan A.
|
||||
138
docs/superpowers/coordination/archive/v0.5.0-dev-b-prompt.md
Normal file
138
docs/superpowers/coordination/archive/v0.5.0-dev-b-prompt.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Dev B Kickoff Prompt — v0.5.0 Plan B (Extension UX)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Plan B for the Relicario v0.5.0 "polish + harden" release. Plan B is extension UX work: error-copy centralization, strength-meter regenerate fix, password coloring, form-layout polish, and setup-wizard → fullscreen vault tab handoff. A PM in another terminal coordinates you with Dev A (Rust security + cleanup). The user relays messages between terminals.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.plan-b -b feature/v0.5.0-plan-b-extension-ux
|
||||
cd ../relicario.plan-b
|
||||
pwd # should print /home/alee/Sources/relicario.plan-b
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.plan-b`**. Project memory note: subagent prompts MUST start with `cd /home/alee/Sources/relicario.plan-b` — otherwise subagents commit to main.
|
||||
|
||||
Today: 2026-05-02. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` — spec (your scope is **B1, P1, P2, P3, P4 only**; B2 is folded into P4)
|
||||
3. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md` — your plan, execute task by task
|
||||
4. `docs/superpowers/specs/2026-05-01-password-coloring-design.md` — spec for P1 (already inlined into your plan, this is the reference design)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (per project memory's default). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per task, two-stage review between tasks.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
```
|
||||
cd /home/alee/Sources/relicario.plan-b
|
||||
```
|
||||
…before any other instruction. This is non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** B1 (strength meter regenerate desync), P4 (error copy centralization, subsumes B2), P1 (password coloring inlined), P3 (form layout envelope), P2 (setup → fullscreen tab handoff).
|
||||
|
||||
**Out of scope:** anything in Plan A (S1, S2, S3, C1). If you trip over a Plan A issue or a new bug while doing your work, file it via a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Don't ship a UI surface that still leaks raw `snake_case` error codes — P4's whole point is centralizing this.
|
||||
- For P3, the spec recommends Approach A (envelope constraint). The plan codifies that. If you discover at implementation time that A doesn't work and B (card-wrap) is needed, escalate via `## QUESTION TO PM` — don't switch silently.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of three terminals. The user relays messages between them.
|
||||
|
||||
**Emit at every task boundary** (when you complete a task, get blocked, or want to ask):
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601 like 2026-05-02T14:30:00-07:00>
|
||||
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <anything PM needs to know — keep to 3 sentences max>
|
||||
```
|
||||
|
||||
**Emit when you need PM input mid-task**:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-B
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no (does work stop without an answer?)
|
||||
```
|
||||
|
||||
**You'll receive (pasted by user)**: `## DIRECTIVE TO DEV-B` blocks from the PM. Acknowledge and act.
|
||||
|
||||
## Authority within the plan
|
||||
|
||||
You don't need PM permission to:
|
||||
- Execute task-to-task per the plan
|
||||
- Make implementation decisions consistent with the plan and spec
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate to PM when:
|
||||
- A scope question outside the plan
|
||||
- A test you can't make green after honest debugging (don't fudge — debug)
|
||||
- A discovered bug not in your plan
|
||||
- Anything destructive (per project rules)
|
||||
- For P3, if Approach A doesn't work and you need to switch to B
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
1. Extension test suite green: `cd extension && pnpm test`
|
||||
2. Extension build green: `cd extension && pnpm build`
|
||||
3. WASM build still green (sanity): `cd .. && cargo build -p relicario-wasm --target wasm32-unknown-unknown`
|
||||
4. Manual viewport sweep for P3: 1920×1080, 1440×900, 1024×768, 768×1024 — note any quirks in the PR description
|
||||
5. Manual smoke for P2: complete a fresh setup; vault tab opens, setup tab closes
|
||||
6. Push the branch: `git push -u origin feature/v0.5.0-plan-b-extension-ux`
|
||||
7. Open PR: `gh pr create --base main --head feature/v0.5.0-plan-b-extension-ux --title "v0.5.0 Plan B: extension UX" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
Implements Plan B for v0.5.0 polish + harden:
|
||||
- P4: centralized ERROR_COPY map (subsumes B2 vault_locked leak)
|
||||
- B1: strength-meter regenerate desync fix (input event dispatch)
|
||||
- P1: password coloring (per the 2026-05-01 spec)
|
||||
- P3: form-layout envelope constraint (Approach A)
|
||||
- P2: setup wizard → fullscreen vault tab handoff
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md
|
||||
|
||||
## Test plan
|
||||
- [x] pnpm test green
|
||||
- [x] pnpm build green
|
||||
- [x] cargo build -p relicario-wasm green
|
||||
- [x] Manual viewport sweep — see notes below
|
||||
- [x] Manual setup-flow smoke — vault tab opens, setup closes
|
||||
- [ ] PM review
|
||||
|
||||
### Viewport sweep notes
|
||||
<fill in any quirks observed at each resolution; "none" is acceptable>
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"`
|
||||
8. Emit `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/v0.5.0-plan-b-extension-ux`), then start Task 1 of Plan B (P4: error-copy map).
|
||||
113
docs/superpowers/coordination/archive/v0.5.0-pm-prompt.md
Normal file
113
docs/superpowers/coordination/archive/v0.5.0-pm-prompt.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# PM Kickoff Prompt — v0.5.0 Polish + Harden
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the Relicario v0.5.0 "polish + harden" release. Two senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all three terminals and relays messages between them.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-02. Project rules in `CLAUDE.md` apply (Spanish flourish, capitalization, autonomy defaults, never run git-destructive commands without asking).
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md` — the bundle spec
|
||||
3. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md` — Dev A's plan (Rust + cleanup)
|
||||
4. `docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md` — Dev B's plan (extension UX)
|
||||
5. `docs/superpowers/audits/2026-05-02-doc-audit.md` — your direct work (8 proposed findings still need action; 6 trivial fixes already merged in commit `900ccf1`)
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from `feature/v0.5.0-plan-a-security-cleanup` and `feature/v0.5.0-plan-b-extension-ux`
|
||||
- **Drive the doc-audit follow-ups directly** (the 8 proposed findings) — this is your hands-on work
|
||||
- Write the `CHANGELOG.md` entry for v0.5.0
|
||||
- Tag `v0.5.0` once everything is integrated **— but only after explicit user approval**
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / CHANGELOG / CLAUDE.md are fine.
|
||||
- Don't deviate from the spec without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag without user approval.
|
||||
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`).
|
||||
|
||||
## Judgment calls in the plans worth flagging
|
||||
|
||||
The subagents who drafted the plans flagged these decisions for your awareness:
|
||||
|
||||
- **Plan A:** `safe_unpack_git_archive` was moved from `relicario-cli` to `relicario-core` so integration tests can reach it (matches the bytes-in/bytes-out core philosophy). Tar-bomb test sets the *header's* claimed size to 2 GiB rather than allocating 1 TiB. Adds `regex` as a runtime dep of `relicario-server`.
|
||||
- **Plan B:** P1 (password coloring) was *inlined* into Plan B rather than referenced. P3 went with Approach A (envelope constraint, not card-wrap). P4 keeps `humanizeError` as a thin shell for non-snake_case translators.
|
||||
|
||||
If any of these conflict with your judgment, raise it with the user before kickoff.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of three terminals. The user relays messages between them.
|
||||
|
||||
**You receive (pasted by user):** a `## STATUS UPDATE — DEV-A` or `## STATUS UPDATE — DEV-B` block, or a `## QUESTION TO PM — DEV-X` block.
|
||||
|
||||
**You emit (for user to paste back):** a `## DIRECTIVE TO DEV-A` (or `DEV-B`) block. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-A
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user at any time, give a current rollup:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — v0.5.0
|
||||
Dev A: <task X of Y, status>
|
||||
Dev B: <task X of Y, status>
|
||||
PM: <which doc finding, status>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Dev A REVIEW-READY", "tag v0.5.0">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check the diff against the spec and plan acceptance criteria
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (no squash — git history is preserved per project rule)
|
||||
5. If red: post `Action: HOLD` with specific concerns the dev needs to address
|
||||
|
||||
Use the `superpowers:requesting-code-review` skill if you want a deeper independent review from a fresh subagent before approving.
|
||||
|
||||
## Doc-audit follow-ups (your direct work)
|
||||
|
||||
The 8 proposed findings in `docs/superpowers/audits/2026-05-02-doc-audit.md` are yours. Pick up while the devs are working in parallel. Pay particular attention to:
|
||||
|
||||
1. `relicario-server` is invisible in cross-codebase docs (`docs/architecture/overview.md`, `CLAUDE.md` project tree)
|
||||
2. `CLAUDE.md` Roadmap line is stale ("Next: WASM extension (Plan 2)")
|
||||
3. `docs/SECURITY.md` overstates current device-auth enforcement — note that S1 is the fix that makes this true
|
||||
|
||||
For findings that touch `CLAUDE.md`, propose the change in a status block to the user — don't edit it without approval.
|
||||
|
||||
## Pre-tag checklist
|
||||
|
||||
Before tagging v0.5.0:
|
||||
|
||||
- [ ] `feature/v0.5.0-plan-a-security-cleanup` merged to main
|
||||
- [ ] `feature/v0.5.0-plan-b-extension-ux` merged to main
|
||||
- [ ] All 8 doc-audit findings actioned (fixed, deferred, or dropped)
|
||||
- [ ] `CHANGELOG.md` entry for v0.5.0 written
|
||||
- [ ] `cargo test` green on main
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
|
||||
- [ ] Extension build green (`cd extension && pnpm build`)
|
||||
- [ ] User-driven smoke test of the merged result
|
||||
- [ ] Pre-v0.3.0 manual test walk done (`docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md`) — bundles forward since v0.3.0 was never tagged
|
||||
- [ ] Explicit user approval to tag
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## RELEASE STATUS` block confirming you've absorbed the spec, both plans, and the audit. Note the three judgment calls in the plans for the user's awareness, and propose your starting doc-audit finding. Wait for user input or a status update from a dev.
|
||||
1464
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
1464
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user