Compare commits
97 Commits
8f78b6dc01
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"superpowers@claude-plugins-official": true
|
"superpowers@claude-plugins-official": true
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ extension/dist-firefox/
|
|||||||
extension/wasm/
|
extension/wasm/
|
||||||
reference.jpg
|
reference.jpg
|
||||||
ref.jpg
|
ref.jpg
|
||||||
|
tools/relay/node_modules/
|
||||||
|
|||||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -1,9 +1,76 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
### 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
|
- **Sync now button** in the extension settings view — surfaces the
|
||||||
previously hidden `{ type: 'sync' }` SW message to users with success /
|
previously hidden `{ type: 'sync' }` SW message to users with success /
|
||||||
error feedback.
|
error feedback.
|
||||||
@@ -59,6 +126,30 @@
|
|||||||
file `cmd_backup_export` writes on success). Reads "never" for
|
file `cmd_backup_export` writes on success). Reads "never" for
|
||||||
fresh vaults, "4 days ago" otherwise.
|
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
|
### Known limitations
|
||||||
|
|
||||||
- **Mid-restore failure leaves the target remote in a half-written
|
- **Mid-restore failure leaves the target remote in a half-written
|
||||||
@@ -74,6 +165,13 @@
|
|||||||
|
|
||||||
### Internal
|
### 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
|
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
|
||||||
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
||||||
extraction; behavior unchanged. The dispatcher matches and delegates.
|
extraction; behavior unchanged. The dispatcher matches and delegates.
|
||||||
@@ -83,14 +181,6 @@
|
|||||||
`setup.ts` since it walks live wizard state. Setup.ts went from
|
`setup.ts` since it walks live wizard state. Setup.ts went from
|
||||||
1205 → 1137 lines.
|
1205 → 1137 lines.
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `relicario generate` now consults `VaultSettings.generator_defaults` when
|
|
||||||
invoked inside an initialized vault. Explicit flags (`--length`,
|
|
||||||
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
|
|
||||||
default. Outside a vault, behavior is unchanged (length 20, safe symbol
|
|
||||||
set, 5 BIP39 words, space separator).
|
|
||||||
|
|
||||||
## v0.2.0 — 2026-04-27
|
## v0.2.0 — 2026-04-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -48,9 +48,11 @@ crates/
|
|||||||
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||||
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||||
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||||
└── relicario-wasm/ # WASM bindings for the extension
|
├── relicario-wasm/ # WASM bindings for the extension
|
||||||
├── src/lib.rs # #[wasm_bindgen] surface
|
│ ├── src/lib.rs # #[wasm_bindgen] surface
|
||||||
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
│ └── 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
|
## Key design decisions
|
||||||
@@ -76,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.
|
- 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.
|
- 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.
|
- Git history is preserved as an audit log — no squashing.
|
||||||
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
||||||
|
|
||||||
@@ -90,4 +92,4 @@ Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2
|
|||||||
|
|
||||||
## Roadmap
|
## 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.
|
||||||
|
|||||||
941
Cargo.lock
generated
941
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,4 +4,5 @@ members = [
|
|||||||
"crates/relicario-core",
|
"crates/relicario-core",
|
||||||
"crates/relicario-cli",
|
"crates/relicario-cli",
|
||||||
"crates/relicario-wasm",
|
"crates/relicario-wasm",
|
||||||
|
"crates/relicario-server",
|
||||||
]
|
]
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -33,7 +33,9 @@ To unlock the vault, you provide your passphrase and point the client at the ref
|
|||||||
|
|
||||||
A git repository containing:
|
A git repository containing:
|
||||||
- `manifest.enc` — opaque binary blob
|
- `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/salt` — a random 32-byte value (not secret)
|
||||||
- `.relicario/params.json` — Argon2id parameters (not secret)
|
- `.relicario/params.json` — Argon2id parameters (not secret)
|
||||||
- `.relicario/devices.json` — authorized device public keys
|
- `.relicario/devices.json` — authorized device public keys
|
||||||
@@ -114,12 +116,23 @@ relicario/
|
|||||||
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
|
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
|
||||||
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
|
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
|
||||||
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
|
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
|
||||||
│ │ ├── entry.rs # Entry, Manifest data model (serde)
|
│ │ ├── item.rs # Item, Field, Manifest data model (serde)
|
||||||
│ │ └── vault.rs # Encrypt/decrypt entries and manifests
|
│ │ ├── item_types/ # Per-type cores (Login, SecureNote, Card, Identity, Key, Document, Totp)
|
||||||
│ └── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
|
│ │ ├── 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/
|
└── docs/
|
||||||
|
├── ARCHITECTURE.md # System overview + flow diagrams
|
||||||
|
├── SECURITY.md # Manifest integrity model + threat notes
|
||||||
|
├── architecture/ # Cross-codebase + per-codebase architecture docs
|
||||||
└── superpowers/
|
└── 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).
|
`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/
|
my-vault.git/
|
||||||
├── manifest.enc # Encrypted entry index (names, URLs, timestamps)
|
├── manifest.enc # Encrypted item index (names, URLs, timestamps)
|
||||||
├── entries/
|
├── settings.enc # Encrypted vault settings (retention, caps, generator defaults)
|
||||||
│ ├── a1b2c3d4.enc # One encrypted entry per file
|
├── items/
|
||||||
│ └── e5f6a7b8.enc
|
│ ├── a1b2c3d4e5f6a7b8.enc # One encrypted item per file
|
||||||
|
│ └── …
|
||||||
|
├── attachments/
|
||||||
|
│ └── <item-id>/
|
||||||
|
│ └── <aid>.enc # Content-addressed encrypted attachment blob
|
||||||
└── .relicario/
|
└── .relicario/
|
||||||
├── salt # 32-byte random salt (not secret)
|
├── salt # 32-byte random salt (not secret)
|
||||||
├── params.json # KDF parameters
|
├── 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
|
## Device management
|
||||||
|
|
||||||
@@ -183,13 +201,17 @@ The binary is at `target/release/relicario`.
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [ ] WASM build + Chrome browser extension (inline crypto, no native messaging)
|
- [x] WASM build + Chrome MV3 browser extension (inline crypto, no native messaging)
|
||||||
- [ ] Secure notes (free-form encrypted text entries)
|
- [x] Firefox WebExtension build
|
||||||
- [ ] Secure document storage (encrypted file attachments up to 5-10 MB)
|
- [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)
|
- [ ] `relicario unlock` daemon (ssh-agent-style, holds master key for a TTL)
|
||||||
- [ ] Android/iOS clients (Rust core compiles to ARM)
|
- [ ] Android/iOS clients (Rust core compiles to ARM)
|
||||||
- [ ] Import from LastPass/Bitwarden/1Password
|
- [ ] Safari extension
|
||||||
- [ ] Firefox/Safari extensions
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ arboard = "3"
|
|||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -28,10 +27,11 @@ tar = { version = "0.4", default-features = false }
|
|||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
rqrr = "0.7"
|
rqrr = "0.7"
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
|
qrcode = { version = "0.14", features = ["svg"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
qrcode = "0.14"
|
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
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.
|
/// Path to the `.relicario/` configuration directory within the vault.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn relicario_dir() -> Result<PathBuf> {
|
pub fn relicario_dir() -> Result<PathBuf> {
|
||||||
Ok(vault_dir()?.join(".relicario"))
|
Ok(vault_dir()?.join(".relicario"))
|
||||||
}
|
}
|
||||||
@@ -88,19 +89,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
|||||||
///
|
///
|
||||||
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||||
/// vault directory. This is intentional — the file feeds shell completion,
|
/// vault directory. This is intentional — the file feeds shell completion,
|
||||||
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
/// which cannot prompt for a passphrase. In debug builds, set
|
||||||
/// to suppress the write.
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
|
||||||
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||||
vault_dir.join(".relicario").join("groups.cache")
|
vault_dir.join(".relicario").join("groups.cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||||
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
/// 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(
|
pub fn write_groups_cache(
|
||||||
vault_dir: &Path,
|
vault_dir: &Path,
|
||||||
groups: &std::collections::BTreeSet<String>,
|
groups: &std::collections::BTreeSet<String>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = groups_cache_path(vault_dir);
|
let path = groups_cache_path(vault_dir);
|
||||||
@@ -115,6 +118,21 @@ pub fn write_groups_cache(
|
|||||||
std::fs::write(path, body)
|
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
|
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
||||||
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
||||||
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
||||||
@@ -179,6 +197,29 @@ mod tests {
|
|||||||
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn 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]
|
#[test]
|
||||||
fn humanize_age_buckets() {
|
fn humanize_age_buckets() {
|
||||||
assert_eq!(humanize_age(0), "just now");
|
assert_eq!(humanize_age(0), "just now");
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! See module docs for the unlock flow and vault layout.
|
//! See module docs for the unlock flow and vault layout.
|
||||||
|
|
||||||
|
mod device;
|
||||||
|
mod gitea;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
@@ -158,15 +160,9 @@ enum Commands {
|
|||||||
/// Sync with the git remote (pull --rebase + push).
|
/// Sync with the git remote (pull --rebase + push).
|
||||||
Sync,
|
Sync,
|
||||||
|
|
||||||
/// Print a summary of the vault: items, attachments, devices, last commit.
|
/// Print a summary of the vault: items, attachments, last commit.
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Device management.
|
|
||||||
Device {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: DeviceAction,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
||||||
Lock,
|
Lock,
|
||||||
|
|
||||||
@@ -174,7 +170,7 @@ enum Commands {
|
|||||||
///
|
///
|
||||||
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||||
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||||
/// which the CLI refreshes on every manifest read. Set
|
/// which the CLI refreshes on every manifest read. In debug builds, set
|
||||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||||
/// will fall back to no value enumeration).
|
/// will fall back to no value enumeration).
|
||||||
///
|
///
|
||||||
@@ -194,6 +190,18 @@ enum Commands {
|
|||||||
/// Passphrase to score, or `-` to read from stdin.
|
/// Passphrase to score, or `-` to read from stdin.
|
||||||
passphrase: String,
|
passphrase: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manage registered devices (signing keys + deploy keys).
|
||||||
|
Device {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: DeviceAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
|
||||||
|
RecoveryQr {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: RecoveryQrCmd,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -312,13 +320,6 @@ enum SettingsAction {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum DeviceAction {
|
|
||||||
Add { #[arg(long)] name: String },
|
|
||||||
List,
|
|
||||||
Revoke { name: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum BackupAction {
|
enum BackupAction {
|
||||||
/// Pack the local vault into a single encrypted `.relbak` file.
|
/// Pack the local vault into a single encrypted `.relbak` file.
|
||||||
@@ -360,6 +361,62 @@ enum ImportAction {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum DeviceAction {
|
||||||
|
/// Register this machine as a new device.
|
||||||
|
///
|
||||||
|
/// Generates two ed25519 keypairs: one for signing commits, one for push
|
||||||
|
/// access (deploy key). The deploy public key is registered via the Gitea
|
||||||
|
/// API. Both private keys are stored locally in
|
||||||
|
/// `~/.config/relicario/devices/<name>/`. The vault's `.relicario/devices.json`
|
||||||
|
/// is updated and committed.
|
||||||
|
///
|
||||||
|
/// Required environment variables (or flags):
|
||||||
|
/// RELICARIO_GITEA_URL — e.g. https://git.example.com
|
||||||
|
/// RELICARIO_GITEA_TOKEN — personal access token with repo write access
|
||||||
|
/// RELICARIO_GITEA_OWNER — repository owner
|
||||||
|
/// RELICARIO_GITEA_REPO — repository name
|
||||||
|
Add {
|
||||||
|
/// Human-readable name for this device (e.g. "laptop-2026").
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
/// Gitea API base URL (overrides RELICARIO_GITEA_URL).
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
/// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN).
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
/// Gitea repository owner (overrides RELICARIO_GITEA_OWNER).
|
||||||
|
#[arg(long)]
|
||||||
|
owner: Option<String>,
|
||||||
|
/// Gitea repository name (overrides RELICARIO_GITEA_REPO).
|
||||||
|
#[arg(long)]
|
||||||
|
repo: Option<String>,
|
||||||
|
/// Skip Gitea API registration (useful when the remote is not Gitea).
|
||||||
|
#[arg(long)]
|
||||||
|
no_gitea: bool,
|
||||||
|
},
|
||||||
|
/// Revoke a registered device.
|
||||||
|
///
|
||||||
|
/// Removes the device from `devices.json`, adds it to `revoked.json`,
|
||||||
|
/// deletes the deploy key from Gitea, and commits the change.
|
||||||
|
Revoke {
|
||||||
|
/// Name of the device to revoke.
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// List registered devices.
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand)]
|
||||||
|
enum RecoveryQrCmd {
|
||||||
|
/// Generate a recovery QR code and display it as ASCII art in the terminal.
|
||||||
|
Generate,
|
||||||
|
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
|
||||||
|
Unwrap,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -385,7 +442,6 @@ fn main() -> Result<()> {
|
|||||||
Commands::Settings { action } => cmd_settings(action),
|
Commands::Settings { action } => cmd_settings(action),
|
||||||
Commands::Sync => cmd_sync(),
|
Commands::Sync => cmd_sync(),
|
||||||
Commands::Status => cmd_status(),
|
Commands::Status => cmd_status(),
|
||||||
Commands::Device { action } => cmd_device(action),
|
|
||||||
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||||
Commands::Completions { shell } => {
|
Commands::Completions { shell } => {
|
||||||
let mut cmd = Cli::command();
|
let mut cmd = Cli::command();
|
||||||
@@ -393,6 +449,8 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
||||||
|
Commands::Device { action } => cmd_device(action),
|
||||||
|
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,11 +472,41 @@ fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::
|
|||||||
let _ = helpers::write_groups_cache(vault_dir, &set);
|
let _ = helpers::write_groups_cache(vault_dir, &set);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for test item secret override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn test_item_secret_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
fn test_item_secret_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for test backup passphrase override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn test_backup_passphrase_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
fn test_backup_passphrase_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
/// unavailable in assert_cmd-spawned children).
|
/// unavailable in assert_cmd-spawned children).
|
||||||
fn prompt_secret(label: &str) -> Result<String> {
|
fn prompt_secret(label: &str) -> Result<String> {
|
||||||
if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") {
|
if let Some(s) = test_item_secret_override() {
|
||||||
return Ok(s);
|
return Ok(s);
|
||||||
}
|
}
|
||||||
rpassword::prompt_password(label).map_err(Into::into)
|
rpassword::prompt_password(label).map_err(Into::into)
|
||||||
@@ -442,12 +530,12 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
// Passphrase with strength gate (audit H3).
|
// Passphrase with strength gate (audit H3).
|
||||||
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||||
// TTY prompt so integration tests can run without a real TTY.
|
// TTY prompt so integration tests can run without a real TTY.
|
||||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
|
let passphrase = if let Some(p) = test_passphrase_override() {
|
||||||
Zeroizing::new(p)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
||||||
};
|
};
|
||||||
let confirm = if std::env::var_os("RELICARIO_TEST_PASSPHRASE").is_some() {
|
let confirm = if test_passphrase_override().is_some() {
|
||||||
passphrase.clone()
|
passphrase.clone()
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
@@ -467,7 +555,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
};
|
};
|
||||||
let carrier = fs::read(&image)
|
let carrier = fs::read(&image)
|
||||||
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||||
let stego = imgsecret::embed(&carrier, &*image_secret)?;
|
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||||
fs::write(&output, &stego)
|
fs::write(&output, &stego)
|
||||||
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||||
|
|
||||||
@@ -477,7 +565,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
|
||||||
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||||
let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?;
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||||
|
|
||||||
fs::create_dir_all(&relicario_dir)?;
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
fs::create_dir_all(root.join("items"))?;
|
fs::create_dir_all(root.join("items"))?;
|
||||||
@@ -497,8 +585,6 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
salt_path: ".relicario/salt".into(),
|
salt_path: ".relicario/salt".into(),
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
fs::write(relicario_dir.join("devices.json"), b"[]")?;
|
|
||||||
|
|
||||||
let manifest = Manifest::new();
|
let manifest = Manifest::new();
|
||||||
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||||
let settings = VaultSettings::default();
|
let settings = VaultSettings::default();
|
||||||
@@ -515,7 +601,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
let status = crate::helpers::git_command(&root, &["init"]).status()?;
|
let status = crate::helpers::git_command(&root, &["init"]).status()?;
|
||||||
if !status.success() { anyhow::bail!("git init failed"); }
|
if !status.success() { anyhow::bail!("git init failed"); }
|
||||||
let _ = crate::helpers::git_command(&root, &[
|
let _ = crate::helpers::git_command(&root, &[
|
||||||
"add", ".gitignore", ".relicario/params.json", ".relicario/devices.json",
|
"add", ".gitignore", ".relicario/params.json",
|
||||||
".relicario/salt", "manifest.enc", "settings.enc",
|
".relicario/salt", "manifest.enc", "settings.enc",
|
||||||
]).status()?;
|
]).status()?;
|
||||||
let status = crate::helpers::git_command(&root, &[
|
let status = crate::helpers::git_command(&root, &[
|
||||||
@@ -562,7 +648,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
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();
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?;
|
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());
|
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -574,6 +660,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
// (for attachment-cap settings + writing the encrypted blob alongside
|
// (for attachment-cap settings + writing the encrypted blob alongside
|
||||||
// the item).
|
// the item).
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_login_item(
|
fn build_login_item(
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
@@ -789,6 +876,7 @@ fn build_document_item(
|
|||||||
Ok(item)
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_totp_item(
|
fn build_totp_item(
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
issuer: Option<String>,
|
issuer: Option<String>,
|
||||||
@@ -853,7 +941,7 @@ fn prompt_optional(label: &str) -> Result<Option<String>> {
|
|||||||
|
|
||||||
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||||
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
|
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||||
let month: u8 = m_str.parse().context("invalid month")?;
|
let month: u8 = m_str.parse().context("invalid month")?;
|
||||||
let year: u16 = if y_str.len() == 2 {
|
let year: u16 = if y_str.len() == 2 {
|
||||||
@@ -927,12 +1015,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
|||||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||||
if let Some(t) = &l.totp {
|
if let Some(t) = &l.totp {
|
||||||
if show {
|
if show {
|
||||||
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||||
} else {
|
} else {
|
||||||
println!("TOTP: **** (use --show to reveal)");
|
println!("TOTP: **** (use --show to reveal)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
l.password.clone()
|
||||||
}
|
}
|
||||||
ItemCore::SecureNote(n) => {
|
ItemCore::SecureNote(n) => {
|
||||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||||
@@ -1054,8 +1142,8 @@ fn cmd_list(
|
|||||||
Some(t) => e.r#type == t,
|
Some(t) => e.r#type == t,
|
||||||
None => true,
|
None => true,
|
||||||
})
|
})
|
||||||
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
|
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||||
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
|
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||||
|
|
||||||
@@ -1064,7 +1152,7 @@ fn cmd_list(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE");
|
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||||
for e in entries {
|
for e in entries {
|
||||||
let fav = if e.favorite { " *" } else { "" };
|
let fav = if e.favorite { " *" } else { "" };
|
||||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||||
@@ -1107,7 +1195,7 @@ fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
|||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
refresh_groups_cache(vault.root(), &manifest);
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
|
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"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Updated {}", item.id.as_str());
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1324,7 +1412,7 @@ fn cmd_rm(query: String) -> Result<()> {
|
|||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
refresh_groups_cache(vault.root(), &manifest);
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
|
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"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Moved to trash: {}", item.title);
|
eprintln!("Moved to trash: {}", item.title);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1342,7 +1430,7 @@ fn cmd_restore(query: String) -> Result<()> {
|
|||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.save_manifest(&manifest)?;
|
||||||
refresh_groups_cache(vault.root(), &manifest);
|
refresh_groups_cache(vault.root(), &manifest);
|
||||||
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
|
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"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Restored: {}", item.title);
|
eprintln!("Restored: {}", item.title);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1422,12 +1510,12 @@ fn cmd_backup_export(
|
|||||||
let root = crate::helpers::vault_dir()?;
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
|
||||||
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
||||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") {
|
let passphrase = if let Some(p) = test_backup_passphrase_override() {
|
||||||
Zeroizing::new(p)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
};
|
};
|
||||||
let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").is_some() {
|
let confirm = if test_backup_passphrase_override().is_some() {
|
||||||
passphrase.clone()
|
passphrase.clone()
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
@@ -1444,8 +1532,11 @@ fn cmd_backup_export(
|
|||||||
.with_context(|| "failed to read .relicario/salt")?;
|
.with_context(|| "failed to read .relicario/salt")?;
|
||||||
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
.with_context(|| "failed to read .relicario/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"))
|
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
||||||
.with_context(|| "failed to read .relicario/devices.json")?;
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
let manifest_enc = fs::read(root.join("manifest.enc"))
|
let manifest_enc = fs::read(root.join("manifest.enc"))
|
||||||
.with_context(|| "failed to read manifest.enc")?;
|
.with_context(|| "failed to read manifest.enc")?;
|
||||||
let settings_enc = fs::read(root.join("settings.enc"))
|
let settings_enc = fs::read(root.join("settings.enc"))
|
||||||
@@ -1569,6 +1660,7 @@ fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
|||||||
fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use relicario_core::backup;
|
use relicario_core::backup;
|
||||||
|
use relicario_core::{ItemId, AttachmentId};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let target = if target.is_absolute() {
|
let target = if target.is_absolute() {
|
||||||
@@ -1591,7 +1683,7 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
||||||
|
|
||||||
// Backup passphrase prompt.
|
// Backup passphrase prompt.
|
||||||
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") {
|
let passphrase = if let Some(p) = test_backup_passphrase_override() {
|
||||||
Zeroizing::new(p)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
@@ -1617,9 +1709,18 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
||||||
|
|
||||||
for item in &unpacked.items {
|
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)?;
|
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
||||||
}
|
}
|
||||||
for a in &unpacked.attachments {
|
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);
|
let dir = target.join("attachments").join(&a.item_id);
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
||||||
@@ -1634,9 +1735,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
|
|
||||||
// .git/ history.
|
// .git/ history.
|
||||||
if let Some(tar_bytes) = &unpacked.git_archive {
|
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||||
archive.unpack(target.join(".git"))
|
let cap = std::cmp::min(
|
||||||
.with_context(|| "failed to untar .git/")?;
|
(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 {
|
} else {
|
||||||
// No history bundled — start a fresh git repo.
|
// No history bundled — start a fresh git repo.
|
||||||
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
||||||
@@ -1799,6 +1923,28 @@ fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
|||||||
|
|
||||||
let bytes = fs::read(&file)
|
let bytes = fs::read(&file)
|
||||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
.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 enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||||
|
|
||||||
let filename = file.file_name()
|
let filename = file.file_name()
|
||||||
@@ -1831,7 +1977,9 @@ fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
|||||||
];
|
];
|
||||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
commit_paths(&vault, &format!("attach: {} → {} ({})",
|
commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||||
file.display(), item.title, item.id.as_str()), &path_refs)?;
|
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());
|
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1842,7 +1990,7 @@ fn cmd_attachments(query: String) -> Result<()> {
|
|||||||
let entry = resolve_query(&manifest, &query)?;
|
let entry = resolve_query(&manifest, &query)?;
|
||||||
let item = vault.load_item(&entry.id)?;
|
let item = vault.load_item(&entry.id)?;
|
||||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||||
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME");
|
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||||
for a in &item.attachments {
|
for a in &item.attachments {
|
||||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||||
}
|
}
|
||||||
@@ -1914,7 +2062,7 @@ fn cmd_detach(query: String, aid: String) -> Result<()> {
|
|||||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||||
commit_paths(
|
commit_paths(
|
||||||
&vault,
|
&vault,
|
||||||
&format!("detach: {} from {} ({})", removed.filename, item.title, item.id.as_str()),
|
&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],
|
&[&item_path, "manifest.enc", &blob_relpath],
|
||||||
)?;
|
)?;
|
||||||
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||||
@@ -2088,8 +2236,6 @@ fn cmd_sync() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_status() -> Result<()> {
|
fn cmd_status() -> Result<()> {
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let root = vault.root().to_path_buf();
|
let root = vault.root().to_path_buf();
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
@@ -2102,16 +2248,6 @@ fn cmd_status() -> Result<()> {
|
|||||||
.flat_map(|e| e.attachment_summaries.iter())
|
.flat_map(|e| e.attachment_summaries.iter())
|
||||||
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||||
|
|
||||||
// devices.json — count entries; missing/empty → 0.
|
|
||||||
let devices_path = root.join(".relicario").join("devices.json");
|
|
||||||
let device_count = match fs::read(&devices_path) {
|
|
||||||
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.as_array().map(|a| a.len()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
Err(_) => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let last_commit = crate::helpers::git_command(&root, &[
|
let last_commit = crate::helpers::git_command(&root, &[
|
||||||
"log", "-1", "--pretty=format:%h %s",
|
"log", "-1", "--pretty=format:%h %s",
|
||||||
]).output()
|
]).output()
|
||||||
@@ -2143,83 +2279,10 @@ fn cmd_status() -> Result<()> {
|
|||||||
println!("Vault: {}", root.display());
|
println!("Vault: {}", root.display());
|
||||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||||
println!("Devices: {device_count}");
|
|
||||||
println!("Last commit: {last_commit}");
|
println!("Last commit: {last_commit}");
|
||||||
println!("Last export: {last_backup_str}");
|
println!("Last export: {last_backup_str}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn cmd_device(action: DeviceAction) -> Result<()> {
|
|
||||||
use std::fs;
|
|
||||||
use ed25519_dalek::SigningKey;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
let root = crate::helpers::vault_dir()?;
|
|
||||||
let devices_path = root.join(".relicario").join("devices.json");
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
struct DeviceEntry { name: String, public_key: String }
|
|
||||||
|
|
||||||
match action {
|
|
||||||
DeviceAction::Add { name } => {
|
|
||||||
let mut existing: Vec<DeviceEntry> =
|
|
||||||
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
||||||
if existing.iter().any(|d| d.name == name) {
|
|
||||||
anyhow::bail!("device `{name}` already exists");
|
|
||||||
}
|
|
||||||
let signing = SigningKey::generate(&mut OsRng);
|
|
||||||
let verifying = signing.verifying_key();
|
|
||||||
let pubkey_hex = hex::encode(verifying.to_bytes());
|
|
||||||
|
|
||||||
existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() });
|
|
||||||
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
|
|
||||||
|
|
||||||
let cfg_dir = dirs::config_dir()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("no config dir"))?
|
|
||||||
.join("relicario").join("devices");
|
|
||||||
fs::create_dir_all(&cfg_dir)?;
|
|
||||||
let key_path = cfg_dir.join(format!("{name}.key"));
|
|
||||||
fs::write(&key_path, signing.to_bytes())?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["add", ".relicario/devices.json"]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git add failed"); }
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["commit", "-m", &format!("device: add {name}")]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git commit failed"); }
|
|
||||||
eprintln!("Added device `{name}` (pubkey: {pubkey_hex})");
|
|
||||||
}
|
|
||||||
DeviceAction::List => {
|
|
||||||
let existing: Vec<DeviceEntry> =
|
|
||||||
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
||||||
if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); }
|
|
||||||
for d in existing {
|
|
||||||
println!("{:<20} {}", d.name, d.public_key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DeviceAction::Revoke { name } => {
|
|
||||||
let mut existing: Vec<DeviceEntry> =
|
|
||||||
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
||||||
let before = existing.len();
|
|
||||||
existing.retain(|d| d.name != name);
|
|
||||||
if existing.len() == before { anyhow::bail!("device `{name}` not found"); }
|
|
||||||
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["add", ".relicario/devices.json"]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git add failed"); }
|
|
||||||
let status = crate::helpers::git_command(&root,
|
|
||||||
&["commit", "-m", &format!("device: revoke {name}")]).status()?;
|
|
||||||
if !status.success() { anyhow::bail!("git commit failed"); }
|
|
||||||
eprintln!("Revoked device `{name}`");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct ParamsFile {
|
struct ParamsFile {
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
@@ -2261,3 +2324,318 @@ fn cmd_rate(passphrase: String) -> Result<()> {
|
|||||||
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Device management ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
let status = crate::helpers::git_command(
|
||||||
|
&root,
|
||||||
|
&["add", ".relicario/devices.json"],
|
||||||
|
)
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git add .relicario/devices.json failed");
|
||||||
|
}
|
||||||
|
let msg = format!("device: register {}", name);
|
||||||
|
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git commit failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
let status = crate::helpers::git_command(&root, &add_args).status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git add failed");
|
||||||
|
}
|
||||||
|
let msg = format!("device: revoke {}", name);
|
||||||
|
let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
|
||||||
|
.status()?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("git commit failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl UnlockedVault {
|
|||||||
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
||||||
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
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)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(
|
Zeroizing::new(
|
||||||
@@ -50,7 +50,7 @@ impl UnlockedVault {
|
|||||||
|
|
||||||
let master_key = derive_master_key(
|
let master_key = derive_master_key(
|
||||||
passphrase.as_bytes(),
|
passphrase.as_bytes(),
|
||||||
&*image_secret,
|
&image_secret,
|
||||||
&salt,
|
&salt,
|
||||||
¶ms,
|
¶ms,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
|
|||||||
// Encrypted blob file is gone.
|
// Encrypted blob file is gone.
|
||||||
let blob_path = v.path()
|
let blob_path = v.path()
|
||||||
.join("attachments")
|
.join("attachments")
|
||||||
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
|
.join("");
|
||||||
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||||
.unwrap().next().unwrap().unwrap().path();
|
.unwrap().next().unwrap().unwrap().path();
|
||||||
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ fn init_creates_expected_layout() {
|
|||||||
let v = TestVault::init();
|
let v = TestVault::init();
|
||||||
assert!(v.path().join(".relicario/salt").exists());
|
assert!(v.path().join(".relicario/salt").exists());
|
||||||
assert!(v.path().join(".relicario/params.json").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("manifest.enc").exists());
|
||||||
assert!(v.path().join("settings.enc").exists());
|
assert!(v.path().join("settings.enc").exists());
|
||||||
assert!(v.path().join("reference.jpg").exists());
|
assert!(v.path().join("reference.jpg").exists());
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ impl TestVault {
|
|||||||
cmd.output().unwrap()
|
cmd.output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
||||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
cmd.current_dir(self.dir.path())
|
cmd.current_dir(self.dir.path())
|
||||||
@@ -91,6 +92,7 @@ impl TestVault {
|
|||||||
cmd.output().unwrap()
|
cmd.output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
cmd.current_dir(self.dir.path())
|
cmd.current_dir(self.dir.path())
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ fn generate_uses_vault_default_length() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_reports_item_attachment_and_device_counts() {
|
fn status_reports_item_and_attachment_counts() {
|
||||||
let v = TestVault::init();
|
let v = TestVault::init();
|
||||||
v.run(&["add", "login", "--title", "active",
|
v.run(&["add", "login", "--title", "active",
|
||||||
"--username", "u", "--password", "p"]);
|
"--username", "u", "--password", "p"]);
|
||||||
@@ -99,8 +99,7 @@ fn status_reports_item_attachment_and_device_counts() {
|
|||||||
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
|
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
|
||||||
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
|
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
|
||||||
|
|
||||||
// 0 devices in default test vault (init does not register one).
|
// device count line removed — device key system was security theater (audit B1).
|
||||||
assert!(lower.contains("device"), "missing devices section: {stdout}");
|
|
||||||
|
|
||||||
// Last-commit line.
|
// Last-commit line.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sha2 = "0.10"
|
|||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
|
|
||||||
# Typed-item additions
|
# Typed-item additions
|
||||||
@@ -30,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
|
|||||||
tar = { version = "0.4", default-features = false }
|
tar = { version = "0.4", default-features = false }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
csv = "1"
|
csv = "1"
|
||||||
|
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -301,12 +301,20 @@ pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result<BackupOutput> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
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))
|
let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
|
||||||
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
|
||||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
let mut key = Zeroizing::new([0u8; 32]);
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
argon
|
argon
|
||||||
.hash_password_into(passphrase, salt, key.as_mut_slice())
|
.hash_password_into(&nfc_passphrase, salt, key.as_mut_slice())
|
||||||
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
|
||||||
Ok(key)
|
Ok(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,23 @@ pub fn derive_master_key(
|
|||||||
Ok(output)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -408,7 +425,7 @@ mod tests {
|
|||||||
blob.extend_from_slice(&[0u8; 16]);
|
blob.extend_from_slice(&[0u8; 16]);
|
||||||
|
|
||||||
let key = Zeroizing::new([0u8; 32]);
|
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 {
|
match err {
|
||||||
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||||
assert_eq!(found, 0x01);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ pub enum RelicarioError {
|
|||||||
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
||||||
BackupSchemaMismatch { found: u32, expected: u32 },
|
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.
|
/// CSV header doesn't match the LastPass column layout.
|
||||||
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
||||||
ImportCsvHeader(String),
|
ImportCsvHeader(String),
|
||||||
@@ -109,6 +113,16 @@ pub enum RelicarioError {
|
|||||||
/// rotating the passphrase or reference image.
|
/// rotating the passphrase or reference image.
|
||||||
#[error("device key error: {0}")]
|
#[error("device key error: {0}")]
|
||||||
DeviceKey(String),
|
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.
|
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
|
//! - `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).
|
//! 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.
|
//! 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::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -29,6 +30,12 @@ impl ItemId {
|
|||||||
Self(hex::encode(bytes))
|
Self(hex::encode(bytes))
|
||||||
}
|
}
|
||||||
pub fn as_str(&self) -> &str { &self.0 }
|
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 {
|
impl Default for ItemId {
|
||||||
@@ -51,9 +58,15 @@ impl Default for FieldId {
|
|||||||
impl AttachmentId {
|
impl AttachmentId {
|
||||||
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
||||||
let digest = Sha256::digest(plaintext);
|
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 }
|
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)]
|
#[cfg(test)]
|
||||||
@@ -106,12 +119,36 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_id_is_16_hex_chars() {
|
fn attachment_id_is_32_hex_chars() {
|
||||||
let id = AttachmentId::from_plaintext(b"any bytes");
|
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()));
|
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]
|
#[test]
|
||||||
fn ids_serialize_as_bare_strings() {
|
fn ids_serialize_as_bare_strings() {
|
||||||
let item = ItemId("abcdef0123456789".to_string());
|
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.
|
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||||
/// ceil(256 / 12) = 22 blocks per copy.
|
/// 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
|
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||||
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
/// (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;
|
return None;
|
||||||
}
|
}
|
||||||
let mut block = [[0.0f64; 8]; 8];
|
let mut block = [[0.0f64; 8]; 8];
|
||||||
for row in 0..8 {
|
for (row, block_row) in block.iter_mut().enumerate() {
|
||||||
for col in 0..8 {
|
for (col, cell) in block_row.iter_mut().enumerate() {
|
||||||
block[row][col] = y.get(px + col, py + row);
|
*cell = y.get(px + col, py + row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(block)
|
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]) {
|
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_x = region.x_offset + bx * BLOCK_SIZE;
|
||||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||||
for row in 0..8 {
|
for (row, block_row) in block.iter().enumerate() {
|
||||||
for col in 0..8 {
|
for (col, &cell) in block_row.iter().enumerate() {
|
||||||
y.set(start_x + col, start_y + row, block[row][col]);
|
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.
|
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 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 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(2.0 / 8.0_f64).sqrt()
|
||||||
};
|
};
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
for i in 0..8 {
|
for (i, &x) in input.iter().enumerate() {
|
||||||
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||||
}
|
}
|
||||||
output[k] = ck * sum;
|
*out_k = ck * sum;
|
||||||
}
|
}
|
||||||
output
|
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)
|
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 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;
|
let mut sum = 0.0;
|
||||||
for k in 0..8 {
|
for (k, &x) in input.iter().enumerate() {
|
||||||
let ck = if k == 0 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(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
|
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.
|
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
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) {
|
for chunk in bits.chunks(8) {
|
||||||
let mut byte = 0u8;
|
let mut byte = 0u8;
|
||||||
for (i, &bit) in chunk.iter().enumerate() {
|
for (i, &bit) in chunk.iter().enumerate() {
|
||||||
|
|||||||
@@ -52,26 +52,23 @@ pub enum TotpAlgorithm {
|
|||||||
Sha512,
|
Sha512,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TotpKind {
|
pub enum TotpKind {
|
||||||
|
#[default]
|
||||||
Totp,
|
Totp,
|
||||||
Hotp { counter: u64 },
|
Hotp { counter: u64 },
|
||||||
Steam,
|
Steam,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TotpKind {
|
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
||||||
fn default() -> Self { TotpKind::Totp }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
|
|
||||||
///
|
///
|
||||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
/// For 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> {
|
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
||||||
let counter = match config.kind {
|
let counter = match config.kind {
|
||||||
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
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,
|
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
||||||
};
|
};
|
||||||
let counter_bytes = counter.to_be_bytes();
|
let counter_bytes = counter.to_be_bytes();
|
||||||
@@ -165,7 +162,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hotp_carries_counter() {
|
fn hotp_kind_roundtrips_through_json() {
|
||||||
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
||||||
let json = serde_json::to_string(&cfg).unwrap();
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
||||||
@@ -173,6 +170,18 @@ mod tests {
|
|||||||
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
||||||
other => panic!("expected Hotp, got {:?}", other),
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -83,3 +83,17 @@ pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupIt
|
|||||||
|
|
||||||
pub mod import_lastpass;
|
pub mod import_lastpass;
|
||||||
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
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,
|
||||||
|
};
|
||||||
|
|||||||
129
crates/relicario-core/src/recovery_qr.rs
Normal file
129
crates/relicario-core/src/recovery_qr.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
|
||||||
|
use rand::RngCore;
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
|
||||||
|
|
||||||
|
const MAGIC: &[u8; 4] = b"RREC";
|
||||||
|
const VERSION: u8 = 0x01;
|
||||||
|
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
|
||||||
|
|
||||||
|
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);
|
||||||
|
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
|
||||||
|
input.extend_from_slice(nfc_bytes);
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
fn production_params() -> KdfParams {
|
||||||
|
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_recovery_qr(
|
||||||
|
passphrase: &str,
|
||||||
|
image_secret: &[u8; 32],
|
||||||
|
) -> Result<RecoveryQrPayload> {
|
||||||
|
generate_recovery_qr_with_params(passphrase, image_secret, &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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unwrap_recovery_qr(
|
||||||
|
payload_bytes: &[u8],
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &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[5..37].try_into().expect("slice length validated above");
|
||||||
|
let wrap_nonce = &payload_bytes[37..61];
|
||||||
|
let ciphertext = &payload_bytes[61..109];
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if !(1..=12).contains(&month) {
|
||||||
return Err("month must be 1..=12");
|
return Err("month must be 1..=12");
|
||||||
}
|
}
|
||||||
if year < 2000 || year > 2099 {
|
if !(2000..=2099).contains(&year) {
|
||||||
return Err("year must be 2000..=2099");
|
return Err("year must be 2000..=2099");
|
||||||
}
|
}
|
||||||
Ok(Self { month, year })
|
Ok(Self { month, year })
|
||||||
|
|||||||
@@ -186,3 +186,30 @@ fn tampered_ciphertext_rejected_as_decrypt_error() {
|
|||||||
other => panic!("expected Decrypt for tampered tag, got {other:?}"),
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
once_cell = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3"
|
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,8 +5,10 @@
|
|||||||
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
||||||
|
|
||||||
mod session;
|
mod session;
|
||||||
|
mod device;
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||||
|
|
||||||
@@ -35,7 +37,8 @@ pub fn unlock(
|
|||||||
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
||||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
.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))
|
Ok(SessionHandle(handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,26 +209,53 @@ pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
use ed25519_dalek::SigningKey;
|
/// Register a new device, generating ed25519 keypairs for signing and deploy.
|
||||||
use base64::Engine;
|
/// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." }
|
||||||
|
/// Private keys are kept internal to WASM and never cross to JS.
|
||||||
/// Generate an ed25519 keypair for device registration.
|
|
||||||
/// Returns JSON: { "public_key_hex": "...", "private_key_base64": "..." }
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn generate_device_keypair() -> Result<JsValue, JsError> {
|
pub fn register_device(name: &str) -> Result<JsValue, JsError> {
|
||||||
let mut rng = rand::thread_rng();
|
let (signing_pub, deploy_pub) =
|
||||||
let signing_key = SigningKey::generate(&mut rng);
|
device::register_device(name).map_err(|e| JsError::new(&e))?;
|
||||||
let verifying_key = signing_key.verifying_key();
|
|
||||||
|
|
||||||
let public_hex = hex::encode(verifying_key.as_bytes());
|
|
||||||
let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.as_bytes());
|
|
||||||
|
|
||||||
js_value_for(&serde_json::json!({
|
js_value_for(&serde_json::json!({
|
||||||
"public_key_hex": public_hex,
|
"signing_public_key": signing_pub,
|
||||||
"private_key_base64": private_b64,
|
"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.
|
/// Extract field history from a decrypted item JSON.
|
||||||
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
@@ -307,6 +337,8 @@ pub fn totp_compute(
|
|||||||
|
|
||||||
// ── Backup container bridge ─────────────────────────────────────────────────
|
// ── Backup container bridge ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
use relicario_core::backup::{
|
use relicario_core::backup::{
|
||||||
pack_backup as core_pack_backup,
|
pack_backup as core_pack_backup,
|
||||||
unpack_backup as core_unpack_backup,
|
unpack_backup as core_unpack_backup,
|
||||||
@@ -454,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
|
|||||||
Ok(json.to_string())
|
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)]
|
#[cfg(test)]
|
||||||
mod session_tests {
|
mod session_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -462,7 +527,7 @@ mod session_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn insert_then_remove_clears_entry() {
|
fn insert_then_remove_clears_entry() {
|
||||||
session::clear();
|
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_ne!(h, 0);
|
||||||
assert!(session::remove(h));
|
assert!(session::remove(h));
|
||||||
assert!(!session::remove(h)); // second remove false
|
assert!(!session::remove(h)); // second remove false
|
||||||
@@ -471,7 +536,7 @@ mod session_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn with_yields_key_only_while_session_lives() {
|
fn with_yields_key_only_while_session_lives() {
|
||||||
session::clear();
|
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]);
|
let byte = session::with(h, |k| k[0]);
|
||||||
assert_eq!(byte, Some(0x22));
|
assert_eq!(byte, Some(0x22));
|
||||||
session::remove(h);
|
session::remove(h);
|
||||||
@@ -483,7 +548,7 @@ mod session_tests {
|
|||||||
fn manifest_round_trip_via_handle() {
|
fn manifest_round_trip_via_handle() {
|
||||||
use relicario_core::{Manifest, decrypt_manifest};
|
use relicario_core::{Manifest, decrypt_manifest};
|
||||||
session::clear();
|
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 handle = SessionHandle(h);
|
||||||
let key = Zeroizing::new([0x55u8; 32]);
|
let key = Zeroizing::new([0x55u8; 32]);
|
||||||
let empty = Manifest::new();
|
let empty = Manifest::new();
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ use std::cell::RefCell;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub struct SessionData {
|
||||||
|
pub master_key: Zeroizing<[u8; 32]>,
|
||||||
|
pub image_secret: Zeroizing<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
thread_local! {
|
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) };
|
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 handle = NEXT_HANDLE.with(|n| {
|
||||||
let mut n = n.borrow_mut();
|
let mut n = n.borrow_mut();
|
||||||
let h = *n;
|
let h = *n;
|
||||||
@@ -19,15 +24,26 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
|
|||||||
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||||
h
|
h
|
||||||
});
|
});
|
||||||
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
|
SESSIONS.with(|s| {
|
||||||
|
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
|
||||||
|
});
|
||||||
handle
|
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>
|
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||||
where
|
where
|
||||||
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(handle: u32) -> bool {
|
pub fn remove(handle: u32) -> bool {
|
||||||
|
|||||||
@@ -42,15 +42,19 @@
|
|||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ GIT SERVER (untrusted) │
|
│ GIT SERVER (untrusted) │
|
||||||
│ │
|
│ │
|
||||||
│ relicario-vault.git/ │
|
│ relicario-vault.git/ │
|
||||||
│ ├── manifest.enc ← opaque ciphertext │
|
│ ├── manifest.enc ← opaque ciphertext │
|
||||||
│ ├── entries/ │
|
│ ├── settings.enc ← opaque ciphertext │
|
||||||
│ │ ├── a1b2c3d4.enc ← opaque ciphertext │
|
│ ├── items/ │
|
||||||
│ │ └── e5f6a7b8.enc ← opaque ciphertext │
|
│ │ ├── a1b2c3d4e5f6a7b8.enc ← opaque ciphertext │
|
||||||
│ └── .relicario/ │
|
│ │ └── … │
|
||||||
|
│ ├── attachments/ │
|
||||||
|
│ │ └── <item-id>/<aid>.enc ← opaque ciphertext │
|
||||||
|
│ └── .relicario/ │
|
||||||
│ ├── salt ← 32 bytes (not secret) │
|
│ ├── salt ← 32 bytes (not secret) │
|
||||||
│ ├── params.json ← KDF params (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, │
|
│ The server sees NOTHING useful. No keys, no plaintext, │
|
||||||
│ no metadata about what's inside. │
|
│ no metadata about what's inside. │
|
||||||
@@ -79,8 +83,9 @@ vault_salt ────────►│ │
|
|||||||
|
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
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
|
│ 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)
|
## Unlock Flow (every vault operation)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -217,21 +230,23 @@ Input JPEG (possibly re-encoded or cropped)
|
|||||||
│ uses
|
│ uses
|
||||||
▼
|
▼
|
||||||
┌────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────┐
|
||||||
│ relicario-core │
|
│ relicario-core │
|
||||||
│ Platform-agnostic: bytes in, bytes out │
|
│ Platform-agnostic: bytes in, bytes out │
|
||||||
│ No filesystem, no network, no git │
|
│ No filesystem, no network, no git │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
|
||||||
│ │ crypto │ │ imgsecret│ │ entry │ │ vault │ │
|
│ │ crypto │ │ imgsecret│ │ item + │ │ vault │ │
|
||||||
│ │ │ │ │ │ │ │ │ │
|
│ │ │ │ │ │ types │ │ │ │
|
||||||
│ │ KDF │ │ DCT │ │ Entry │ │ encrypt_ │ │
|
│ │ KDF │ │ DCT │ │ Item │ │ encrypt_ │ │
|
||||||
│ │ encrypt │ │ embed │ │ Manifest│ │ entry() │ │
|
│ │ encrypt │ │ embed │ │ Manifest│ │ item() │ │
|
||||||
│ │ decrypt │ │ extract │ │ search │ │ decrypt_ │ │
|
│ │ decrypt │ │ extract │ │ Settings│ │ decrypt_ │ │
|
||||||
│ │ │ │ QIM │ │ │ │ manifest() │ │
|
│ │ │ │ QIM │ │ Backup │ │ manifest() │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
|
│ │ │ │ │ │ Device │ │ ... │ │
|
||||||
|
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ Future: relicario-wasm wraps this for browser extension │
|
│ Consumed by: relicario-cli, relicario-wasm (extension), │
|
||||||
│ Future: JNI/Swift wrappers for Android/iOS │
|
│ 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. |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Architecture overview — Relicario
|
# Architecture overview — Relicario
|
||||||
|
|
||||||
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
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:
|
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
|
||||||
>
|
>
|
||||||
@@ -10,44 +10,48 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
|
|||||||
>
|
>
|
||||||
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
||||||
|
|
||||||
## The three codebases
|
## The four codebases
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
│ relicario-core │
|
│ relicario-core │
|
||||||
│ (Rust, no I/O) │
|
│ (Rust, no I/O) │
|
||||||
│ crypto · items │
|
│ crypto · items │
|
||||||
│ manifest · stego │
|
│ manifest · stego │
|
||||||
└──────────┬──────────┘
|
│ device keys + fp │
|
||||||
│
|
└──┬───────────┬──────┘
|
||||||
┌─────────────────────┼─────────────────────┐
|
│ │
|
||||||
│ │ │
|
┌────────────────┼───────────┴──────┬────────────────────┐
|
||||||
▼ ▼ ▼
|
│ │ │ │
|
||||||
┌────────────────┐ ┌────────────────────┐ (compiled to WASM
|
▼ ▼ ▼ ▼
|
||||||
│ relicario-cli │ │ relicario-wasm │ inside the )
|
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||||
│ (Rust binary) │ │ (#[wasm_bindgen] │ extension │
|
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
|
||||||
│ │ │ bindings) │ │
|
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
|
||||||
│ filesystem + │ │ │ │
|
│ │ │ │ │ bindings) │
|
||||||
│ git + │ └────────┬───────────┘ │
|
│ filesystem + │ │ pre-receive hook │ │ │
|
||||||
│ clap UX │ │ │
|
│ git + │ │ verify-commit + │ │ compiled to WASM │
|
||||||
└────────────────┘ ▼ │
|
│ clap UX │ │ generate-hook │ │ for the extension │
|
||||||
┌─────────────────────┐ │
|
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
|
||||||
│ extension │ │
|
│
|
||||||
│ (TypeScript) │ │
|
▼
|
||||||
│ popup · vault │ │
|
┌─────────────────────┐
|
||||||
│ setup · content │ │
|
│ extension │
|
||||||
│ service worker │ │
|
│ (TypeScript) │
|
||||||
└─────────────────────┘
|
│ popup · vault │
|
||||||
|
│ setup · content │
|
||||||
|
│ service worker │
|
||||||
|
└─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| Codebase | Language | Role | Key boundary |
|
| Codebase | Language | Role | Key boundary |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
|
| `relicario-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-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-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. |
|
| `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 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
|
## Inter-codebase contracts
|
||||||
|
|
||||||
@@ -151,6 +155,7 @@ The CLI keeps its master key in process memory; if the process exits or crashes,
|
|||||||
| Target | Tool | Output | When to run |
|
| Target | Tool | Output | When to run |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
|
||||||
@@ -177,8 +182,8 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Master key only in `Zeroizing<[u8;32]>` | core types; CLI follows; extension WASM follows | Drop-on-scope-exit zeroization; never leaves stack |
|
| 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 |
|
| AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly |
|
||||||
| Item IDs are random 8-char hex | `core/ids.rs` | Stable, short, no information leak |
|
| Item IDs are random 16-char hex (64 bits) | `core/ids.rs` | Stable, short, no information leak |
|
||||||
| Attachment IDs are content-addressed (SHA-256) | `core/ids.rs` | Dedup; integrity check |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log |
|
||||||
@@ -196,6 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|
|||||||
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/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 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 |
|
| 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` |
|
| 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) |
|
| 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) |
|
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
|
||||||
|
|||||||
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.**
|
||||||
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.
|
||||||
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.
|
||||||
1448
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
1448
docs/superpowers/coordination/v0.5.1-dev-a-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
1074
docs/superpowers/coordination/v0.5.1-dev-b-prompt.md
Normal file
1074
docs/superpowers/coordination/v0.5.1-dev-b-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
1353
docs/superpowers/coordination/v0.5.1-dev-c-prompt.md
Normal file
1353
docs/superpowers/coordination/v0.5.1-dev-c-prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
165
docs/superpowers/coordination/v0.5.1-pm-prompt.md
Normal file
165
docs/superpowers/coordination/v0.5.1-pm-prompt.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# PM Kickoff Prompt — v0.5.1 UX Polish + Recovery QR
|
||||||
|
|
||||||
|
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.1 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four 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-03. 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-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — full spec
|
||||||
|
3. `docs/superpowers/coordination/v0.5.1-dev-a-prompt.md` — Dev A's plan (Stream A: fullscreen + popup layout)
|
||||||
|
4. `docs/superpowers/coordination/v0.5.1-dev-b-prompt.md` — Dev B's plan (Stream B: settings UX)
|
||||||
|
5. `docs/superpowers/coordination/v0.5.1-dev-c-prompt.md` — Dev C's plan (Stream C: recovery QR)
|
||||||
|
|
||||||
|
## Your authority
|
||||||
|
|
||||||
|
- Approve or deny scope changes from devs
|
||||||
|
- Review and merge PRs from all three feature branches
|
||||||
|
- **Drive the interface contract** between B and C (see below) — this is your first hands-on action
|
||||||
|
- Write the `CHANGELOG.md` entry for v0.5.1
|
||||||
|
- Tag `v0.5.1` 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.
|
||||||
|
|
||||||
|
## Stream overview
|
||||||
|
|
||||||
|
| Stream | Branch | Owner | Core files |
|
||||||
|
|--------|--------|-------|-----------|
|
||||||
|
| A — Fullscreen + popup layout | `feature/v0.5.1-stream-a-layout` | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `item-form.ts`, `glyphs.ts`, `toast.ts` |
|
||||||
|
| B — Settings UX | `feature/v0.5.1-stream-b-settings` | DEV-B | `settings.ts`, `settings-vault.ts` (decomposed), `settings-security.ts` (stub only) |
|
||||||
|
| C — Recovery QR | `feature/v0.5.1-stream-c-recovery-qr` | DEV-C | `recovery_qr.rs`, WASM `session.rs`/`lib.rs`, `settings-security.ts`, `setup.ts` |
|
||||||
|
|
||||||
|
## Interface contracts (enforce before work starts)
|
||||||
|
|
||||||
|
### A–B: Settings component signature
|
||||||
|
|
||||||
|
DEV-B's settings component is wired into vault.ts by DEV-A. Both must agree before either proceeds with their vault.ts / settings.ts work.
|
||||||
|
|
||||||
|
**Agreed interface** (post to both devs as your first directive):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// extension/src/popup/components/settings.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the full sectioned settings view into `container`.
|
||||||
|
* May be called from vault.ts (fullscreen, full-width pane) or popup.ts (popup).
|
||||||
|
*/
|
||||||
|
export async function renderSettings(container: HTMLElement): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teardown: close any open generator panel, remove keyboard listeners.
|
||||||
|
* Call before navigating away from the settings view.
|
||||||
|
*/
|
||||||
|
export function teardownSettings(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
DEV-A imports `{ renderSettings, teardownSettings }` from `settings.ts` in vault.ts.
|
||||||
|
DEV-B exports these names with these exact signatures.
|
||||||
|
|
||||||
|
### B–C: Security section component signature
|
||||||
|
|
||||||
|
DEV-C owns and implements `settings-security.ts`. DEV-B imports it for the Security section. They must agree before DEV-B writes B4 (Security section) or DEV-C writes C8 (settings-security.ts).
|
||||||
|
|
||||||
|
**Agreed interface** (post to both devs as your first directive):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// extension/src/popup/components/settings-security.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the three-state Recovery QR + trusted devices security section
|
||||||
|
* into `container`. `sessionHandle` is the current WASM session handle value
|
||||||
|
* (from the service-worker's session), or null if the vault is locked.
|
||||||
|
*/
|
||||||
|
export async function renderSecuritySection(
|
||||||
|
container: HTMLElement,
|
||||||
|
sessionHandle: number | null,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teardown: remove any event listeners attached during render.
|
||||||
|
*/
|
||||||
|
export function teardownSecuritySection(): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
DEV-B stubs this interface in `settings-security.ts` immediately after receiving this directive. DEV-C replaces it with the real implementation.
|
||||||
|
|
||||||
|
## Merge order and strategy
|
||||||
|
|
||||||
|
1. **C lands first** (or concurrently with A; no A or B dependency). Merge once DEV-C posts REVIEW-READY.
|
||||||
|
2. **A and B can merge in either order** after C is on main, since both will rebase/merge main before PR.
|
||||||
|
3. No squash merges — git history is preserved per project rule.
|
||||||
|
4. No force pushes. Each dev opens a PR; PM reviews diff; PM merges with `gh pr merge --merge`.
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
You are one of four terminals. The user relays messages.
|
||||||
|
|
||||||
|
**You receive:** `## STATUS UPDATE — DEV-A/B/C` or `## QUESTION TO PM — DEV-X` blocks.
|
||||||
|
|
||||||
|
**You emit:** a `## DIRECTIVE TO DEV-X` block. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## DIRECTIVE TO DEV-A (or B or C)
|
||||||
|
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:
|
||||||
|
|
||||||
|
```
|
||||||
|
## RELEASE STATUS — v0.5.1
|
||||||
|
Dev A: <task X of Y, status>
|
||||||
|
Dev B: <task X of Y, status>
|
||||||
|
Dev C: <task X of Y, status>
|
||||||
|
PM: <current action>
|
||||||
|
Blockers: <list, or "none">
|
||||||
|
Next milestone: <e.g., "Dev C REVIEW-READY", "all three 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 diff against the spec sections owned by that stream
|
||||||
|
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge`
|
||||||
|
5. If red: post `Action: HOLD` with specific concerns
|
||||||
|
|
||||||
|
## Pre-tag checklist
|
||||||
|
|
||||||
|
Before tagging v0.5.1:
|
||||||
|
|
||||||
|
- [ ] `feature/v0.5.1-stream-a-layout` merged to main
|
||||||
|
- [ ] `feature/v0.5.1-stream-b-settings` merged to main
|
||||||
|
- [ ] `feature/v0.5.1-stream-c-recovery-qr` merged to main
|
||||||
|
- [ ] `cargo test` green on main
|
||||||
|
- [ ] `bun run test` green (extension)
|
||||||
|
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green
|
||||||
|
- [ ] `bun run build` + `bun run build:firefox` clean (extension)
|
||||||
|
- [ ] No emoji in any UI surface (grep: `'🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️'` in `extension/src/`)
|
||||||
|
- [ ] `GLYPH_VAULT_TAB` in glyphs.ts; no inline `⤴` anywhere
|
||||||
|
- [ ] `recovery_qr_generated_at` is the only persisted QR artifact (grep: no QR SVG in chrome.storage calls)
|
||||||
|
- [ ] Settings left-nav sections all render without console errors
|
||||||
|
- [ ] `CHANGELOG.md` entry for v0.5.1 written
|
||||||
|
- [ ] Explicit user approval to tag
|
||||||
|
|
||||||
|
## First action
|
||||||
|
|
||||||
|
After reading: post a `## RELEASE STATUS — v0.5.1` block, then post your first directive to all three devs simultaneously — confirming the A–B and B–C interface contracts above. Wait for devs to acknowledge before instructing them to proceed with their task lists.
|
||||||
1257
docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md
Normal file
1257
docs/superpowers/plans/2026-05-02-phase-2b-polish-and-form-layout.md
Normal file
File diff suppressed because it is too large
Load Diff
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
# Relay Server Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a local MCP SSE server that gives PM, Dev-A, and Dev-B Claude Code sessions native `post_message`/`read_messages`/`list_pending` tools, eliminating manual copy-paste during multi-agent development lifts.
|
||||||
|
|
||||||
|
**Architecture:** A single Node.js process hosts an HTTP server with SSE transport for the MCP protocol. Three named in-memory FIFO queues (one per role) hold consume-once messages. A `start.sh` launcher prints copy-paste instructions (default) or spawns a tmux/kitty layout (flags). The multi-agent-kickoff skill templates get a `<<RELAY_PARAGRAPH>>` placeholder injected so every future lift prompt auto-includes relay instructions.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js v25, `@modelcontextprotocol/sdk` (MCP + SSE transport), `tsx` (dev dep, runs TypeScript directly), Node built-in `node:test` runner. No Express, no Hono, no Zod as a direct dep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| Action | Path | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Create | `tools/relay/package.json` | npm metadata, scripts, single runtime dep + tsx devDep |
|
||||||
|
| Create | `tools/relay/tsconfig.json` | TypeScript config for ESM Node target |
|
||||||
|
| Create | `tools/relay/queue.ts` | `RelayQueue` class — in-memory FIFO, `post`/`read`/`pending`, `isRole` guard |
|
||||||
|
| Create | `tools/relay/queue.test.ts` | Node `node:test` unit tests for queue (5 cases) |
|
||||||
|
| Create | `tools/relay/server.ts` | MCP `Server` + `SSEServerTransport` HTTP server on port 7331 |
|
||||||
|
| Create | `tools/relay/start.sh` | Launcher: `--manual` (default), `--tmux`, `--kitty` |
|
||||||
|
| Modify | `.gitignore` | Add `tools/relay/node_modules/` |
|
||||||
|
| Modify | `.claude/settings.json` | Add `mcpServers.relay` SSE entry |
|
||||||
|
| Create | `docs/superpowers/MULTI-AGENT.md` | Paradigm reference README |
|
||||||
|
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||||
|
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||||
|
| Modify | `~/.claude/skills/multi-agent-kickoff/SKILL.md` | Placeholder ref + step 8 update + `<<DEV_ROLE>>` placeholder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold `tools/relay/`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/package.json`
|
||||||
|
- Create: `tools/relay/tsconfig.json`
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `tools/relay/package.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@relicario/relay",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx tsx server.ts",
|
||||||
|
"test": "node --import=tsx/esm --test queue.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `tools/relay/tsconfig.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add to root `.gitignore`**
|
||||||
|
|
||||||
|
Open `/home/alee/Sources/relicario/.gitignore` and append:
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/relay/node_modules/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Install dependencies and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `node_modules/` created, no errors. Verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls node_modules/@modelcontextprotocol/sdk && ls node_modules/tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both directories exist.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit scaffold**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/package.json tools/relay/tsconfig.json tools/relay/package-lock.json .gitignore
|
||||||
|
git commit -m "chore(relay): scaffold tools/relay with MCP SDK dep"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `queue.ts` — TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/queue.ts`
|
||||||
|
- Create: `tools/relay/queue.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tools/relay/queue.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, beforeEach } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { RelayQueue, isRole } from "./queue.ts";
|
||||||
|
|
||||||
|
describe("RelayQueue", () => {
|
||||||
|
let q: RelayQueue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
q = new RelayQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("post + read roundtrip returns the message with correct fields", () => {
|
||||||
|
q.post("dev-b", "pm", "status", "Task P4 DONE");
|
||||||
|
const msgs = q.read("pm");
|
||||||
|
assert.equal(msgs.length, 1);
|
||||||
|
assert.equal(msgs[0].from, "dev-b");
|
||||||
|
assert.equal(msgs[0].to, "pm");
|
||||||
|
assert.equal(msgs[0].kind, "status");
|
||||||
|
assert.equal(msgs[0].body, "Task P4 DONE");
|
||||||
|
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
|
||||||
|
assert.ok(typeof msgs[0].ts === "string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consume-once: second read returns empty", () => {
|
||||||
|
q.post("dev-a", "pm", "question", "Should I use approach A?");
|
||||||
|
q.read("pm");
|
||||||
|
const second = q.read("pm");
|
||||||
|
assert.deepEqual(second, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list_pending does not drain inbox", () => {
|
||||||
|
q.post("dev-b", "pm", "directive", "PROCEED");
|
||||||
|
const before = q.pending("pm");
|
||||||
|
assert.equal(before.count, 1);
|
||||||
|
const after = q.read("pm");
|
||||||
|
assert.equal(after.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIFO ordering across multiple senders", () => {
|
||||||
|
q.post("dev-a", "pm", "status", "first");
|
||||||
|
q.post("dev-b", "pm", "status", "second");
|
||||||
|
q.post("dev-a", "pm", "question", "third");
|
||||||
|
const msgs = q.read("pm");
|
||||||
|
assert.equal(msgs.length, 3);
|
||||||
|
assert.equal(msgs[0].body, "first");
|
||||||
|
assert.equal(msgs[1].body, "second");
|
||||||
|
assert.equal(msgs[2].body, "third");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isRole rejects unknown strings", () => {
|
||||||
|
assert.ok(isRole("pm"));
|
||||||
|
assert.ok(isRole("dev-a"));
|
||||||
|
assert.ok(isRole("dev-b"));
|
||||||
|
assert.ok(!isRole("dev-c"));
|
||||||
|
assert.ok(!isRole(""));
|
||||||
|
assert.ok(!isRole("PM"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fails with `Cannot find module './queue.ts'` or similar. If it fails with a different error, investigate before continuing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `queue.ts`**
|
||||||
|
|
||||||
|
Create `tools/relay/queue.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export type Role = "pm" | "dev-a" | "dev-b";
|
||||||
|
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||||
|
|
||||||
|
export interface RelayMessage {
|
||||||
|
id: string;
|
||||||
|
from: Role;
|
||||||
|
to: Role;
|
||||||
|
kind: MessageKind;
|
||||||
|
body: string;
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
|
||||||
|
|
||||||
|
export function isRole(s: string): s is Role {
|
||||||
|
return KNOWN_ROLES.has(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayQueue {
|
||||||
|
private readonly queues = new Map<Role, RelayMessage[]>([
|
||||||
|
["pm", []],
|
||||||
|
["dev-a", []],
|
||||||
|
["dev-b", []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||||
|
const msg: RelayMessage = {
|
||||||
|
id: randomUUID(),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
kind,
|
||||||
|
body,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.queues.get(to)!.push(msg);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
read(forRole: Role): RelayMessage[] {
|
||||||
|
const inbox = this.queues.get(forRole)!;
|
||||||
|
const messages = [...inbox];
|
||||||
|
inbox.length = 0;
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
|
||||||
|
const inbox = this.queues.get(forRole)!;
|
||||||
|
return {
|
||||||
|
count: inbox.length,
|
||||||
|
kinds: inbox.map((m) => m.kind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to confirm they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (all 5 passing):
|
||||||
|
```
|
||||||
|
▶ RelayQueue
|
||||||
|
✔ post + read roundtrip returns the message with correct fields
|
||||||
|
✔ consume-once: second read returns empty
|
||||||
|
✔ list_pending does not drain inbox
|
||||||
|
✔ FIFO ordering across multiple senders
|
||||||
|
✔ isRole rejects unknown strings
|
||||||
|
▶ RelayQueue (Xms)
|
||||||
|
ℹ tests 5
|
||||||
|
ℹ pass 5
|
||||||
|
ℹ fail 0
|
||||||
|
```
|
||||||
|
|
||||||
|
If any test fails, fix `queue.ts` before proceeding.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/queue.ts tools/relay/queue.test.ts
|
||||||
|
git commit -m "feat(relay): in-memory queue with consume-once semantics"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `server.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/server.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `server.ts`**
|
||||||
|
|
||||||
|
Create `tools/relay/server.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import http from "node:http";
|
||||||
|
import { RelayQueue, isRole } from "./queue.ts";
|
||||||
|
|
||||||
|
const PORT = 7331;
|
||||||
|
const queue = new RelayQueue();
|
||||||
|
|
||||||
|
const mcpServer = new Server(
|
||||||
|
{ name: "relay", version: "0.1.0" },
|
||||||
|
{ capabilities: { tools: {} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
name: "post_message",
|
||||||
|
description:
|
||||||
|
"Push a message to a recipient's inbox. Returns the assigned message id.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
from: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Recipient role name",
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["status", "question", "directive", "free"],
|
||||||
|
description: "Message type matching the coordination protocol",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "string",
|
||||||
|
description: "Message body — freeform markdown, typically the full formatted block",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["from", "to", "kind", "body"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read_messages",
|
||||||
|
description:
|
||||||
|
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
for: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["for"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_pending",
|
||||||
|
description:
|
||||||
|
"Return count and kinds of pending messages without consuming them.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
for: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["for"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||||
|
|
||||||
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
const a = args as Record<string, string>;
|
||||||
|
|
||||||
|
if (name === "post_message") {
|
||||||
|
if (!isRole(a.from)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
|
||||||
|
}
|
||||||
|
if (!isRole(a.to)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const kind = a.kind as "status" | "question" | "directive" | "free";
|
||||||
|
const msg = queue.post(a.from, a.to, kind, a.body);
|
||||||
|
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||||
|
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
|
||||||
|
const ellipsis = a.body.length > 60 ? "..." : "";
|
||||||
|
process.stdout.write(`[${ts}] ${a.from} → ${a.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "read_messages") {
|
||||||
|
if (!isRole(a.for)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const messages = queue.read(a.for);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "list_pending") {
|
||||||
|
if (!isRole(a.for)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const result = queue.pending(a.for);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method === "GET" && req.url === "/sse") {
|
||||||
|
const transport = new SSEServerTransport("/message", res);
|
||||||
|
transports.set(transport.sessionId, transport);
|
||||||
|
transport.onclose = () => transports.delete(transport.sessionId);
|
||||||
|
await mcpServer.connect(transport);
|
||||||
|
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
|
const sessionId = url.searchParams.get("sessionId") ?? "";
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
if (transport) {
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "session not found" }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.writeHead(404).end("not found");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[relay] error:", err);
|
||||||
|
if (!res.headersSent) res.writeHead(500).end(String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, "127.0.0.1", () => {
|
||||||
|
console.log(`[relay] server ready on :${PORT}`);
|
||||||
|
console.log(`[relay] tools: post_message, read_messages, list_pending`);
|
||||||
|
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-test server startup**
|
||||||
|
|
||||||
|
In one terminal:
|
||||||
|
```bash
|
||||||
|
cd tools/relay && npx tsx server.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
[relay] server ready on :7331
|
||||||
|
[relay] tools: post_message, read_messages, list_pending
|
||||||
|
[relay] waiting for connections — Ctrl-C to stop
|
||||||
|
```
|
||||||
|
|
||||||
|
In a second terminal, verify the port is listening:
|
||||||
|
```bash
|
||||||
|
curl -s --max-time 2 http://127.0.0.1:7331/sse | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: SSE `data:` stream begins (it won't complete — the connection stays open). Ctrl-C both.
|
||||||
|
|
||||||
|
If the server errors on startup, check that `@modelcontextprotocol/sdk` is installed and review any TypeScript errors by running `npx tsc --noEmit` in `tools/relay/`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/server.ts
|
||||||
|
git commit -m "feat(relay): MCP SSE server with post_message/read_messages/list_pending"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `start.sh`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/start.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `start.sh`**
|
||||||
|
|
||||||
|
Create `tools/relay/start.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
|
||||||
|
PORT=7331
|
||||||
|
MODE="manual"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--tmux) MODE="tmux" ;;
|
||||||
|
--kitty) MODE="kitty" ;;
|
||||||
|
--manual) MODE="manual" ;;
|
||||||
|
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Port check
|
||||||
|
if lsof -ti:"$PORT" &>/dev/null; then
|
||||||
|
echo "Error: port $PORT is already in use."
|
||||||
|
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install deps (no-op if node_modules current)
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
npm install --silent
|
||||||
|
|
||||||
|
# Discover latest coordination prompts for instructions
|
||||||
|
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
|
||||||
|
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
||||||
|
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
|
||||||
|
print_manual_instructions() {
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Open 3 new terminals. In each, start Claude Code and paste"
|
||||||
|
echo "the content BELOW the '---' line from the corresponding file."
|
||||||
|
echo ""
|
||||||
|
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
||||||
|
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
||||||
|
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
||||||
|
echo ""
|
||||||
|
echo "This terminal becomes the relay log. Keep it open."
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_tmux() {
|
||||||
|
SESSION="relay-lift"
|
||||||
|
tmux new-session -d -s "$SESSION" -n "relay" \
|
||||||
|
"cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||||
|
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
||||||
|
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
||||||
|
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
||||||
|
echo ""
|
||||||
|
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
|
||||||
|
echo "[relay] Paste the kickoff prompt into each Claude window."
|
||||||
|
echo " Prompts:"
|
||||||
|
echo " PM: $PM_PROMPT"
|
||||||
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
echo ""
|
||||||
|
tmux attach-session -t "$SESSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_kitty() {
|
||||||
|
kitty @ launch --new-tab --tab-title "relay" -- \
|
||||||
|
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||||
|
kitty @ launch --new-window --window-title "PM" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
kitty @ launch --new-window --window-title "Dev-A" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
kitty @ launch --new-window --window-title "Dev-B" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
echo ""
|
||||||
|
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
||||||
|
echo " Paste the kickoff prompts into each Claude window."
|
||||||
|
echo " PM: $PM_PROMPT"
|
||||||
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
manual)
|
||||||
|
print_manual_instructions
|
||||||
|
exec npx tsx "$SCRIPT_DIR/server.ts"
|
||||||
|
;;
|
||||||
|
tmux)
|
||||||
|
launch_tmux
|
||||||
|
;;
|
||||||
|
kitty)
|
||||||
|
launch_kitty
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Make executable**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x tools/relay/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke-test `--manual` mode**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario && tools/relay/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: prints the launch box with prompt paths, then server starts and shows `[relay] server ready on :7331`. Ctrl-C to stop.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/start.sh
|
||||||
|
git commit -m "feat(relay): start.sh launcher with --manual/--tmux/--kitty modes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Claude Code MCP configuration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude/settings.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current `.claude/settings.json`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat .claude/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the relay MCP server entry**
|
||||||
|
|
||||||
|
The file currently has `{ "enabledPlugins": { ... } }`. Add `"mcpServers"` at the top level:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve whatever is already in `enabledPlugins` — only add the `mcpServers` key.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .claude/settings.json
|
||||||
|
git commit -m "chore(relay): add relay MCP server to project Claude config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: `docs/superpowers/MULTI-AGENT.md`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/MULTI-AGENT.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the paradigm README**
|
||||||
|
|
||||||
|
Create `docs/superpowers/MULTI-AGENT.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 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
|
||||||
|
|
||||||
|
Use the `multi-agent-kickoff` skill (in the `superpowers` plugin). It auto-discovers the spec and plans for the release, substitutes all placeholders including the relay paragraph, and writes files to `docs/superpowers/coordination/`.
|
||||||
|
|
||||||
|
The skill reminder: run `tools/relay/start.sh` **before** opening the three Claude Code sessions — the MCP tools need the server to be 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/MULTI-AGENT.md
|
||||||
|
git commit -m "docs: add multi-agent development paradigm README"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Update `multi-agent-kickoff` skill
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`
|
||||||
|
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`
|
||||||
|
- Modify: `~/.claude/skills/multi-agent-kickoff/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current templates**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||||
|
cat ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Note where the "Setup" section ends in each template. The relay paragraph goes right after it (before "Required reading").
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `<<RELAY_PARAGRAPH>>` to `pm-prompt.md`**
|
||||||
|
|
||||||
|
In `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`, find the "## Setup" section and add the placeholder block immediately after it (before the "## Required reading" heading):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<<RELAY_PARAGRAPH>>
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated output for this placeholder (substituted by the skill at generation time) is:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 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`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-a", kind="directive", body="...")`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `<<RELAY_PARAGRAPH>>` to `dev-prompt.md`**
|
||||||
|
|
||||||
|
In `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`, find the "## Setup" section and add immediately after it (before "## Required reading"):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<<RELAY_PARAGRAPH>>
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated output for this placeholder is role-specific (uses `<<DEV_ROLE>>`):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 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_ROLE>>"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="<<DEV_ROLE>>"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="<<DEV_ROLE>>")`. After emitting any status/question block: `post_message(from="<<DEV_ROLE>>", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `SKILL.md` — add two entries to the placeholder reference table**
|
||||||
|
|
||||||
|
In `~/.claude/skills/multi-agent-kickoff/SKILL.md`, find the "### Common to all prompts" section of the Placeholder reference and add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `<<RELAY_PARAGRAPH>>` — the relay server instruction block (substituted from the template above). For the PM prompt, `from` is hardcoded to `"pm"`. For dev prompts, uses `<<DEV_ROLE>>`.
|
||||||
|
```
|
||||||
|
|
||||||
|
In the "### Per-dev" section, add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `<<DEV_ROLE>>` — lowercase relay role name, e.g. `dev-a`, `dev-b`. Derived from `<<DEV_LETTER>>` by lowercasing and prepending `dev-`. Set when `<<DEV_LETTER>>` is set.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `SKILL.md` — step 8 (kickoff instructions)**
|
||||||
|
|
||||||
|
Find step 8 in the Process section ("Print kickoff instructions") and prepend a bullet:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
8. **Print kickoff instructions.** Tell the user exactly what to do:
|
||||||
|
- **Start the relay server first:** `tools/relay/start.sh` (or `--tmux`/`--kitty` for auto-layout). The server must be running before the sessions open so the MCP tools initialize correctly.
|
||||||
|
- Open three terminal windows (or panes — their choice of multiplexer)
|
||||||
|
...rest of existing bullets unchanged...
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the "After generation" section — change bullet 4 ("From that point on, they're the message bus...") to:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
4. The relay server handles message routing — agents call `post_message`/`read_messages` directly. The user only needs to step in for escalations the PM can't resolve, or if the relay server is down (manual fallback: copy-paste the block to the relevant terminal as before)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit skill changes**
|
||||||
|
|
||||||
|
The skill files live outside the git repo, so no git commit needed. Verify the changes look right:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "RELAY_PARAGRAPH\|DEV_ROLE\|relay server" ~/.claude/skills/multi-agent-kickoff/SKILL.md | head -10
|
||||||
|
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||||
|
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: each grep returns at least one match.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] **Run queue tests one more time from repo root**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 5 passing, 0 failing.
|
||||||
|
|
||||||
|
- [ ] **Start server and verify it binds**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/relay/start.sh &
|
||||||
|
sleep 1
|
||||||
|
curl -s --max-time 1 http://127.0.0.1:7331/sse | head -1 || true
|
||||||
|
kill %1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `data:` line appears (SSE stream started), then server killed cleanly.
|
||||||
|
|
||||||
|
- [ ] **Verify MCP config is present**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import json; d=json.load(open('.claude/settings.json')); print(d['mcpServers']['relay'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{'type': 'sse', 'url': 'http://localhost:7331/sse'}`
|
||||||
|
|
||||||
|
- [ ] **Verify skill placeholders were added**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md \
|
||||||
|
~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one match per file.
|
||||||
1035
docs/superpowers/plans/2026-05-02-security-blocker-fixes.md
Normal file
1035
docs/superpowers/plans/2026-05-02-security-blocker-fixes.md
Normal file
File diff suppressed because it is too large
Load Diff
1984
docs/superpowers/plans/2026-05-02-security-fixes-and-device-auth.md
Normal file
1984
docs/superpowers/plans/2026-05-02-security-fixes-and-device-auth.md
Normal file
File diff suppressed because it is too large
Load Diff
1528
docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md
Normal file
1528
docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
1654
docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md
Normal file
1654
docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
# Relicario — Design Specification
|
# Relicario — Design Specification
|
||||||
|
|
||||||
|
> **Status:** historical. V1 shipped 2026-04-22; several "Post-V1 Ideas" listed below (typed items, attachments, secure documents, TOTP, Firefox extension, LastPass import, device authentication) have since shipped. See `CHANGELOG.md` and `docs/architecture/overview.md` for current state. Do not edit this spec as if it were architecture documentation — it is a time-stamped decision artifact.
|
||||||
|
|
||||||
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
|
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
# Device Authentication Design
|
||||||
|
|
||||||
|
> **Status:** Approved
|
||||||
|
> **Date:** 2026-05-02
|
||||||
|
> **Author:** Claude + alee
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Relicario device authentication provides cryptographic proof of commit authorship and API-managed access control. Each device (CLI instance, browser extension) has its own identity consisting of:
|
||||||
|
|
||||||
|
1. **Signing key** (ed25519) — signs git commits
|
||||||
|
2. **Deploy key** (ed25519) — grants git push access via Gitea API
|
||||||
|
|
||||||
|
Device management is fully self-contained within Relicario — no manual SSH key management or server admin panels required.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- All commits cryptographically signed by a registered device
|
||||||
|
- Revocation instantly cuts off both signing authority AND push access
|
||||||
|
- CLI and extension have full feature parity
|
||||||
|
- Server-side enforcement via pre-receive hook
|
||||||
|
- No security theater — every feature actually works
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vault Repository │
|
||||||
|
│ .relicario/devices.json ←── public signing keys (ed25519 OpenSSH) │
|
||||||
|
│ .relicario/revoked.json ←── revoked keys + timestamps │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
│ sign commits │ verify signatures
|
||||||
|
│ manage deploy keys (Gitea API) │
|
||||||
|
│ │
|
||||||
|
┌───────────┴───────────┐ ┌─────────────┴─────────────┐
|
||||||
|
│ CLI Device │ │ Gitea Server │
|
||||||
|
│ ~/.config/relicario │ │ pre-receive hook │
|
||||||
|
│ /devices/<name>/ │ │ (relicario-server) │
|
||||||
|
│ signing.key │ └───────────────────────────┘
|
||||||
|
│ deploy.key │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ Extension Device │
|
||||||
|
│ chrome.storage │
|
||||||
|
│ (encrypted keys) │
|
||||||
|
│ signing in WASM │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Storage
|
||||||
|
|
||||||
|
### CLI Device
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/relicario/devices/
|
||||||
|
├── macbook-cli/
|
||||||
|
│ ├── signing.key # OpenSSH private key (ed25519) for commit signing
|
||||||
|
│ ├── signing.pub # OpenSSH public key
|
||||||
|
│ ├── deploy.key # OpenSSH private key (ed25519) for git push
|
||||||
|
│ ├── deploy.pub # OpenSSH public key
|
||||||
|
│ └── gitea_key_id # Gitea's ID for the deploy key (for revocation)
|
||||||
|
└── current # File containing active device name
|
||||||
|
```
|
||||||
|
|
||||||
|
All private keys stored with mode 0600.
|
||||||
|
|
||||||
|
### Extension Device
|
||||||
|
|
||||||
|
- Private keys stored in `chrome.storage.local` under `device_keys`
|
||||||
|
- Encrypted at rest using `HKDF(master_key, "device-storage")`
|
||||||
|
- WASM holds decrypted keys in memory only while session is active
|
||||||
|
- Structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_name": "chrome-macos",
|
||||||
|
"signing_private_key": "<encrypted>",
|
||||||
|
"signing_public_key": "ssh-ed25519 AAAA...",
|
||||||
|
"deploy_private_key": "<encrypted>",
|
||||||
|
"deploy_public_key": "ssh-ed25519 AAAA...",
|
||||||
|
"gitea_key_id": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vault Files
|
||||||
|
|
||||||
|
**`devices.json`:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "macbook-cli",
|
||||||
|
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
|
||||||
|
"added_at": 1714600000,
|
||||||
|
"added_by": "macbook-cli"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`revoked.json`:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "stolen-laptop",
|
||||||
|
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...",
|
||||||
|
"revoked_at": 1714700000,
|
||||||
|
"revoked_by": "macbook-cli"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vault Configuration
|
||||||
|
|
||||||
|
Stored encrypted in vault settings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"git_provider": "gitea",
|
||||||
|
"git_api_url": "https://git.adlee.work/api/v1",
|
||||||
|
"git_api_token": "...",
|
||||||
|
"repo_owner": "alee",
|
||||||
|
"repo_name": "relicario-vault"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required Gitea token scopes: `repo`, `admin:repo_key`
|
||||||
|
|
||||||
|
## CLI Flows
|
||||||
|
|
||||||
|
### Device Add
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario device add --name "macbook-cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Generate ed25519 signing keypair (OpenSSH format)
|
||||||
|
2. Generate ed25519 deploy keypair (OpenSSH format)
|
||||||
|
3. Call Gitea API: `POST /repos/{owner}/{repo}/keys`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "relicario-macbook-cli",
|
||||||
|
"key": "ssh-ed25519 AAAA...",
|
||||||
|
"read_only": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Store keys to `~/.config/relicario/devices/macbook-cli/`
|
||||||
|
5. Write device name to `~/.config/relicario/devices/current`
|
||||||
|
6. Append public signing key to `.relicario/devices.json`
|
||||||
|
7. Configure local git repo:
|
||||||
|
```
|
||||||
|
git config user.signingkey ~/.config/relicario/devices/macbook-cli/signing.key
|
||||||
|
git config gpg.format ssh
|
||||||
|
git config commit.gpgsign true
|
||||||
|
git config core.sshCommand "ssh -i ~/.config/relicario/devices/macbook-cli/deploy.key"
|
||||||
|
```
|
||||||
|
8. Commit: `device: add macbook-cli`
|
||||||
|
9. Push
|
||||||
|
|
||||||
|
### Device Revoke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario device revoke stolen-laptop
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Read `devices.json`, find entry for `stolen-laptop`
|
||||||
|
2. Call Gitea API: `DELETE /repos/{owner}/{repo}/keys/{key_id}`
|
||||||
|
3. Remove from `devices.json`
|
||||||
|
4. Append to `revoked.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "stolen-laptop",
|
||||||
|
"public_key": "ssh-ed25519 AAAA...",
|
||||||
|
"revoked_at": 1714700000,
|
||||||
|
"revoked_by": "macbook-cli"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
5. Commit: `device: revoke stolen-laptop`
|
||||||
|
6. Push immediately
|
||||||
|
|
||||||
|
### Device List
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario device list
|
||||||
|
|
||||||
|
DEVICE ADDED STATUS
|
||||||
|
macbook-cli 2024-05-01 active (current)
|
||||||
|
chrome-macos 2024-05-02 active
|
||||||
|
stolen-laptop 2024-04-15 revoked 2024-05-01
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario verify [commit-ish]
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks signature against `devices.json`, reports device name and status.
|
||||||
|
|
||||||
|
### Sync (Enhanced)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
relicario sync
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Verify HEAD is signed by current device
|
||||||
|
2. Pull with rebase
|
||||||
|
3. Warn on unsigned or unknown-signed incoming commits
|
||||||
|
4. Push
|
||||||
|
|
||||||
|
## Extension/WASM Flows
|
||||||
|
|
||||||
|
### WASM API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn register_device(session: &SessionHandle, name: &str) -> Result<JsValue, JsError>
|
||||||
|
// Generates both keypairs, stores encrypted, returns public keys only
|
||||||
|
// Returns: { signing_public_key: "ssh-ed25519...", deploy_public_key: "ssh-ed25519..." }
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn sign_for_git(session: &SessionHandle, data: &[u8]) -> Result<JsValue, JsError>
|
||||||
|
// Loads encrypted signing key, decrypts, signs, returns signature
|
||||||
|
// Returns: { signature: "base64..." }
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_device_info(session: &SessionHandle) -> Result<JsValue, JsError>
|
||||||
|
// Returns: { name, signing_public_key, deploy_public_key } or null
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn clear_device(session: &SessionHandle) -> Result<(), JsError>
|
||||||
|
// Removes device keys from storage (for re-registration)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical constraint:** Private key bytes never cross WASM boundary to JS. Generated in WASM, encrypted in WASM, decrypted in WASM, used in WASM.
|
||||||
|
|
||||||
|
### Extension Registration Flow
|
||||||
|
|
||||||
|
1. User clicks "Register this device" in settings
|
||||||
|
2. Prompt for device name (default: "Chrome on macOS")
|
||||||
|
3. Call WASM `register_device(name)` → returns public keys
|
||||||
|
4. Service worker calls Gitea API to register deploy key
|
||||||
|
5. Service worker updates `devices.json`, commits, pushes
|
||||||
|
6. Device is now registered
|
||||||
|
|
||||||
|
### Extension Commit Signing
|
||||||
|
|
||||||
|
When extension modifies vault:
|
||||||
|
1. Service worker prepares commit
|
||||||
|
2. Calls WASM `sign_for_git(commit_data)` → returns signature
|
||||||
|
3. Creates signed commit using git SSH signature format
|
||||||
|
4. Pushes using deploy key
|
||||||
|
|
||||||
|
## Server-Side Verification
|
||||||
|
|
||||||
|
### Hook Distribution
|
||||||
|
|
||||||
|
**Option B — CLI generates:**
|
||||||
|
```bash
|
||||||
|
relicario server-hook generate > pre-receive
|
||||||
|
chmod +x pre-receive
|
||||||
|
# Copy to Gitea hooks directory
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C — Standalone binary:**
|
||||||
|
```bash
|
||||||
|
cargo install relicario-server
|
||||||
|
# Or download prebuilt binary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Receive Hook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification Logic
|
||||||
|
|
||||||
|
`relicario-server verify-commit <commit>`:
|
||||||
|
|
||||||
|
1. Extract `devices.json` and `revoked.json` from repo at commit
|
||||||
|
2. Get commit signature via `git verify-commit --raw`
|
||||||
|
3. Parse signature, extract signing public key
|
||||||
|
4. Check key against `devices.json`:
|
||||||
|
- Not found → reject "signed by unregistered device"
|
||||||
|
5. Check key against `revoked.json`:
|
||||||
|
- Found AND commit timestamp ≥ revoked_at → reject "signed by revoked device"
|
||||||
|
- Found AND commit timestamp < revoked_at → accept (historical)
|
||||||
|
6. Accept
|
||||||
|
|
||||||
|
### Gitea Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Per-repo hook
|
||||||
|
cp pre-receive /path/to/gitea-data/git/repositories/alee/vault.git/hooks/pre-receive
|
||||||
|
|
||||||
|
# Or via Gitea admin UI
|
||||||
|
# Settings → Git Hooks → pre-receive → paste script
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Device Registration
|
||||||
|
|
||||||
|
| Error | CLI | Extension |
|
||||||
|
|-------|-----|-----------|
|
||||||
|
| Gitea API unreachable | Fail: "cannot reach git server" | Toast + retry |
|
||||||
|
| API token invalid | Fail: "API token rejected" | Prompt re-enter in settings |
|
||||||
|
| Deploy key name collision | Append `-2`, `-3` or fail | Same |
|
||||||
|
|
||||||
|
### Signing
|
||||||
|
|
||||||
|
| Error | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| No device registered | Block: "run `relicario device add`" |
|
||||||
|
| Private key not found | Prompt re-registration |
|
||||||
|
| Key decryption fails | Session expired, prompt unlock |
|
||||||
|
|
||||||
|
### Server Verification
|
||||||
|
|
||||||
|
| Error | Hook Response |
|
||||||
|
|-------|---------------|
|
||||||
|
| Unsigned commit | Reject: "all commits must be signed" |
|
||||||
|
| Unknown signing key | Reject: "signed by unregistered device" |
|
||||||
|
| Revoked key (post-revocation) | Reject: "signed by revoked device 'X'" |
|
||||||
|
|
||||||
|
### Revocation Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Revoke current device | Require `--confirm`, warn about access loss |
|
||||||
|
| Revoke last device | Error: "cannot revoke last device" |
|
||||||
|
| Gitea API fails during revoke | Revoke signing key, warn about manual deploy key cleanup |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (relicario-core)
|
||||||
|
|
||||||
|
- Key generation and OpenSSH format serialization
|
||||||
|
- Sign/verify round-trip
|
||||||
|
- `devices.json` / `revoked.json` serialization
|
||||||
|
|
||||||
|
### Integration Tests (relicario-cli)
|
||||||
|
|
||||||
|
- `device add` creates keys and configures git
|
||||||
|
- `device revoke` updates both JSON files
|
||||||
|
- Commits are signed after device add
|
||||||
|
- `verify` accepts/rejects appropriately
|
||||||
|
|
||||||
|
### Integration Tests (Gitea API)
|
||||||
|
|
||||||
|
- Mock Gitea API for deploy key management
|
||||||
|
- Graceful failure on API errors
|
||||||
|
|
||||||
|
### WASM Tests
|
||||||
|
|
||||||
|
- `register_device` returns only public keys
|
||||||
|
- `sign_for_git` never exposes private key
|
||||||
|
- Round-trip signing works
|
||||||
|
|
||||||
|
### E2E Tests (Server Hook)
|
||||||
|
|
||||||
|
- Unsigned commits rejected
|
||||||
|
- Valid signatures accepted
|
||||||
|
- Revoked device signatures rejected (post-revocation)
|
||||||
|
- Historical commits by later-revoked devices accepted
|
||||||
|
|
||||||
|
## Bootstrapping
|
||||||
|
|
||||||
|
**Problem:** The first device can't sign its own registration commit — there's no device yet.
|
||||||
|
|
||||||
|
**Solution:** Bootstrap exception in the pre-receive hook:
|
||||||
|
|
||||||
|
1. `relicario init` creates vault with empty `devices.json` (unsigned commit allowed)
|
||||||
|
2. First `device add` registers itself (this commit is also unsigned — no prior device)
|
||||||
|
3. Hook logic: if `devices.json` is empty in the parent commit, allow unsigned
|
||||||
|
4. All subsequent commits must be signed
|
||||||
|
|
||||||
|
**Extension bootstrap:** If connecting to an existing vault that has no devices:
|
||||||
|
1. Extension detects empty `devices.json`
|
||||||
|
2. Prompts to register as first device
|
||||||
|
3. Same unsigned-commit exception applies
|
||||||
|
|
||||||
|
**Security implication:** Anyone with push access can add the first device. This is acceptable because:
|
||||||
|
- Push access already requires git credentials
|
||||||
|
- The hook isn't installed yet anyway on a fresh repo
|
||||||
|
- Once first device is registered, all subsequent changes require signing
|
||||||
|
|
||||||
|
## Security Properties
|
||||||
|
|
||||||
|
1. **Commit authorship is cryptographically proven** — ed25519 signatures
|
||||||
|
2. **Revocation is instant and complete** — deploy key deletion via API
|
||||||
|
3. **Private keys never leave their device** — WASM constraint enforced
|
||||||
|
4. **History is append-only** — revocation doesn't invalidate past commits
|
||||||
|
5. **Server enforces, client assists** — hook is authoritative, client checks are UX
|
||||||
|
6. **Bootstrap is explicit** — first device registration requires push access, then locked down
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- Hosted Relicario service with per-user isolated git backends
|
||||||
|
- Support for other git providers (GitHub, GitLab) via their deploy key APIs
|
||||||
|
- Hardware key support (YubiKey) for signing key storage
|
||||||
338
docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md
Normal file
338
docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# Phase 2B: Polish Foundation + Form Layout
|
||||||
|
|
||||||
|
**Date:** 2026-05-02
|
||||||
|
**Status:** Spec, awaiting review
|
||||||
|
**Surface:** Browser extension — popup, fullscreen vault, setup wizard
|
||||||
|
**Parent spec:** `docs/superpowers/specs/2026-04-30-relicario-fullscreen-ux-redesign-design.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Bring the extension up to a "professional, contained" feel — like opening 1Password or another polished password manager — without losing the terminal-monospace soul. Three surfaces (login popup, setup wizard, fullscreen vault) all get the same polish vocabulary applied. Phase 2B also lands the two-column login form layout from the parent spec.
|
||||||
|
|
||||||
|
## What changed from the original Phase 2B scope
|
||||||
|
|
||||||
|
The original Phase 2B was scoped to form layout only. After visual review, we expanded scope to include:
|
||||||
|
|
||||||
|
- A **patina** palette shift — gold accent dialed from bright `#d2ab43` toward weathered `#a88a4a`/`#cdb47a`/`#5a3f12`. Red theca dialed from saturated `#9a1a1a` toward brick `#7d2622`.
|
||||||
|
- **Logo update** — same composition, patina palette, translucent gradient gem.
|
||||||
|
- **Polish vocabulary** — backdrop with subtle radial glow + 18px grid texture, glass cards (translucent panels with backdrop-blur), refined typography lockup, primary/secondary button hierarchy.
|
||||||
|
- **Arrow glyph** — `▸` (U+25B8) for "next" buttons, matching the `▾`/`▸` disclosure glyphs already in use.
|
||||||
|
|
||||||
|
These items now ship together so the form layout lands inside an already-polished surface, rather than as a layout change inside flat CSS.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Three-pane shell, keyboard nav, command palette — deferred to Phase 3.
|
||||||
|
- New affordances — Phase 2A already shipped the 8 smart inputs.
|
||||||
|
- Light theme — single dark theme stays.
|
||||||
|
- Mobile/narrow layouts under 720px — popup handles narrow.
|
||||||
|
- Animated transitions / motion — focus state is the only transition.
|
||||||
|
- Item types other than `login` getting a two-column treatment.
|
||||||
|
|
||||||
|
## Visual language
|
||||||
|
|
||||||
|
The polish vocabulary lives in `extension/src/popup/styles.css` and `extension/src/vault/vault.css`. Both files share token definitions and class names where possible.
|
||||||
|
|
||||||
|
### Palette (patina)
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Patina gold — replaces the bright amber */
|
||||||
|
--gold-base: #a88a4a; /* base, less yellow / more bronze */
|
||||||
|
--gold-mid: #cdb47a; /* duller mid-highlight */
|
||||||
|
--gold-shadow: #5a3f12; /* deeper bronze shadow */
|
||||||
|
--gold-text: #c9a868; /* legible on dark, brand text */
|
||||||
|
--gold-soft: rgba(184,149,86,0.14); /* hover/active fill */
|
||||||
|
--gold-ring: rgba(184,149,86,0.18); /* focus ring */
|
||||||
|
--gold-stroke: #b89556; /* default border on emphasized elements */
|
||||||
|
|
||||||
|
/* Surface — slightly deeper than current bg */
|
||||||
|
--bg-base: #0a0e14; /* page (was #0d1117) */
|
||||||
|
--bg-pane: #11161e; /* slightly elevated surface */
|
||||||
|
--bg-card: rgba(22, 27, 34, 0.55); /* glass card fill */
|
||||||
|
--bg-input: #0a0e14; /* matches base for sunken feel */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-soft: rgba(255,255,255,0.05); /* card edges */
|
||||||
|
--border-mid: #262d36; /* input borders */
|
||||||
|
--border-warm: #2a3140; /* slightly warmer for vault.css */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--text-dim: #6b7888;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The bright accent token `--accent: #d2ab43` is renamed to `--gold-base: #a88a4a`. Aliases keep existing component code working during the migration (`--accent: var(--gold-base)`).
|
||||||
|
|
||||||
|
### Backdrop
|
||||||
|
|
||||||
|
A reusable backdrop applied to popup body, setup wizard body, and the vault shell:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.surface-backdrop {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 700px 240px at 50% -40px, rgba(184,149,86,0.05), transparent 65%),
|
||||||
|
linear-gradient(180deg, #11161e 0%, #0a0e14 100%);
|
||||||
|
}
|
||||||
|
.surface-backdrop::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255,255,255,0.012) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px);
|
||||||
|
background-size: 18px 18px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.surface-backdrop > * { position: relative; z-index: 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The radial top-glow opacity is intentionally low (`0.05`) so it doesn't wash out on cheaper monitors. The grid texture is barely visible (`0.012` white) — adds a sense of "place" without becoming busy.
|
||||||
|
|
||||||
|
### Glass card
|
||||||
|
|
||||||
|
Used for the unlock card, setup step card, mode-picker cards, and form section cards (Identity / Credentials):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.glass {
|
||||||
|
background: rgba(22, 27, 34, 0.55);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(255,255,255,0.03) inset,
|
||||||
|
0 6px 18px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Browsers without `backdrop-filter` support fall back gracefully — the card stays semi-translucent over the backdrop without the blur.
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
Two clear tiers:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--gold-base);
|
||||||
|
color: var(--bg-base);
|
||||||
|
border: none;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #c9a868; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing `.btn` class keeps existing styling for backwards compatibility; new `.btn-primary` / `.btn-secondary` are used in updated views.
|
||||||
|
|
||||||
|
### Typography lockup
|
||||||
|
|
||||||
|
Logo + brand + tagline group with tighter spacing on login and setup:
|
||||||
|
|
||||||
|
- Logo mark: 40-44px square, 9-10px corner radius, inner highlight only (no outer glow).
|
||||||
|
- Brand text: weight 600, color `var(--gold-text)`, letter-spacing 0.5px.
|
||||||
|
- Tagline: 11px, `var(--text-dim)`, letter-spacing 0.3px.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
```css
|
||||||
|
.input {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--gold-stroke);
|
||||||
|
box-shadow: 0 0 0 2px var(--gold-ring);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arrow glyph
|
||||||
|
|
||||||
|
The `▸` (U+25B8, small right triangle) replaces ASCII `→` in "next" buttons. Reuses the existing disclosure-glyph vocabulary already used in `▾ custom sections` / `▸ attachments`.
|
||||||
|
|
||||||
|
## Logo update
|
||||||
|
|
||||||
|
`extension/icons/relicario-logo.svg` and `extension/icons/relicario-logo-16.svg` updated:
|
||||||
|
|
||||||
|
- Gold gradient stops shifted: `#d2ab43 → #f5d97a → #7c5719` becomes `#a88a4a → #cdb47a → #5a3f12`.
|
||||||
|
- Red theca radial: `#9a1a1a → #3a0a0a` becomes `#7d2622 → #2c0d0a`.
|
||||||
|
- Highlight gradient: `#fde9a8 → #d2ab43` becomes `#dac8a0 → #a88a4a`.
|
||||||
|
- Solid gold tones (`#7c5719`, `#fff3cf`, `#8a5e1c`) remapped to patina equivalents.
|
||||||
|
- Center asterisk gem now translucent: facets use vertical gradients (`gemFacetLight` / `gemFacetDark`) that fade to transparent at the tip; gem core uses a radial glass gradient (`gemCore`); two refraction highlights replace the single white-yellow dot.
|
||||||
|
|
||||||
|
The composition (pedestal, theca, gem, hinge collar, fleur-de-lis) is unchanged.
|
||||||
|
|
||||||
|
## Surface-by-surface changes
|
||||||
|
|
||||||
|
### Login popup (`extension/src/popup/`)
|
||||||
|
|
||||||
|
`unlock.ts` view:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ [logo] │
|
||||||
|
│ Relicario │
|
||||||
|
│ two-factor vault │
|
||||||
|
│ │
|
||||||
|
│ ┌─[ glass card ]──────────────┐ │
|
||||||
|
│ │ UNLOCK │ │
|
||||||
|
│ │ [passphrase input ] │ │
|
||||||
|
│ │ [ unlock vault ] │ │ ← btn-primary, full width
|
||||||
|
│ └──────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [open vault] [settings] │ ← btn-secondary, demoted
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Body gets `.surface-backdrop`.
|
||||||
|
- Logo lockup grouped (logo / brand / tagline) with tighter spacing (8-12px between).
|
||||||
|
- Form moves into `.glass` card with `UNLOCK` label inside.
|
||||||
|
- Primary action is a real button ("unlock vault") — replaces the "press Enter to submit" implicit flow.
|
||||||
|
- Open-vault and settings demoted to secondary buttons below the card.
|
||||||
|
|
||||||
|
### Setup wizard (`extension/src/setup/`)
|
||||||
|
|
||||||
|
- Body gets `.surface-backdrop`.
|
||||||
|
- Header lockup at top (logo + "Relicario vault setup").
|
||||||
|
- Progress dots get a tiny shadow on the current step (`box-shadow: 0 0 4px rgba(184,149,86,0.4)`).
|
||||||
|
- Each `wizard-step` becomes a `.glass` card.
|
||||||
|
- Mode-picker cards become smaller `.glass` cards with patina active state.
|
||||||
|
- All "next" buttons use `▸` glyph.
|
||||||
|
|
||||||
|
### Fullscreen vault (`extension/src/vault/`)
|
||||||
|
|
||||||
|
- Body gets `.surface-backdrop`.
|
||||||
|
- Form section panels (Identity, Credentials) are `.glass` cards.
|
||||||
|
- Save bar matches glass treatment with translucent fill + backdrop-blur.
|
||||||
|
- Form layout switches to two-column for login (see below).
|
||||||
|
|
||||||
|
## Form layout (login, fullscreen only)
|
||||||
|
|
||||||
|
The original Phase 2B scope:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ edit login ⌘+S to save │
|
||||||
|
│ unsaved · esc to cancel │
|
||||||
|
├──────────────────────────┬─────────────────────────────────┤
|
||||||
|
│ [ glass: IDENTITY ] │ [ glass: CREDENTIALS ] │
|
||||||
|
│ title [required] │ username │
|
||||||
|
│ url + ⤓ │ password ⊙ ↻ │
|
||||||
|
│ group (autocomplete) │ strength: ████░ │
|
||||||
|
│ │ totp secret ◫ │
|
||||||
|
│ │ live: 492 837 · 23s │
|
||||||
|
├──────────────────────────┴─────────────────────────────────┤
|
||||||
|
│ NOTES │
|
||||||
|
│ ▾ custom sections ▸ attachments │
|
||||||
|
├────────────────────────────────────────────────────────────┤
|
||||||
|
│ STICKY SAVE BAR [cancel] [save] │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout rules
|
||||||
|
|
||||||
|
- Form pane content: `max-width: 960px`, `margin: 0 auto`.
|
||||||
|
- Two-column wrapper: `display: grid; grid-template-columns: 1fr 1fr; gap: 24px;`.
|
||||||
|
- Below 720px viewport: `grid-template-columns: 1fr` (single column stack).
|
||||||
|
- Notes / custom sections / attachments live in a sibling block below the grid, full-width.
|
||||||
|
|
||||||
|
### Column assignment (login)
|
||||||
|
|
||||||
|
**Left column — IDENTITY:**
|
||||||
|
- title (required pill)
|
||||||
|
- url + `⤓` fill-from-tab button + hostname chip below
|
||||||
|
- group input + datalist autocomplete
|
||||||
|
|
||||||
|
**Right column — CREDENTIALS:**
|
||||||
|
- username
|
||||||
|
- password + `⊙` reveal + `↻` generate; strength bar below the input
|
||||||
|
- totp secret + `◫` QR button; live preview below the input
|
||||||
|
|
||||||
|
**Full-width below grid:**
|
||||||
|
- notes (with `≡` mono toggle)
|
||||||
|
- custom sections / fields disclosure
|
||||||
|
- attachments disclosure
|
||||||
|
|
||||||
|
### Sticky save bar
|
||||||
|
|
||||||
|
- `position: sticky; bottom: 0;` inside the form pane scroll container.
|
||||||
|
- Translucent fill matching the glass vocabulary, with a 24px gradient fade above (content scrolls under).
|
||||||
|
- Right-aligned `[cancel] [save]` buttons.
|
||||||
|
- Save button reflects validity state — disabled when required fields are empty.
|
||||||
|
|
||||||
|
### Header treatment
|
||||||
|
|
||||||
|
- Title (left): `new login` / `edit login`, weight 500, size 18px.
|
||||||
|
- Subtitle (left, below title): `unsaved · esc to cancel` (dirty) or `no changes` (pristine), `var(--text-muted)`, size 12px.
|
||||||
|
- Hint (right): `⌘+S to save` (visual only — actual save shortcut arrives in Phase 3 keymap), `var(--text-dim)`, size 12px. Renders `Ctrl+S` on non-mac.
|
||||||
|
- Popout-to-tab `⤴` removed from fullscreen forms (already done in Phase 1).
|
||||||
|
|
||||||
|
### Other item types
|
||||||
|
|
||||||
|
Single-column stays for `secure_note`, `identity`, `card`, `key`, `totp`, `document`. They still get the new glass-card treatment around the form section, sticky save bar, and header treatment — only the column grid is login-specific.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `extension/icons/relicario-logo.svg` | Patina palette + gradient glass gem |
|
||||||
|
| `extension/icons/relicario-logo-16.svg` | Patina palette (toolbar size) |
|
||||||
|
| `extension/src/popup/styles.css` | Patina tokens, `.surface-backdrop`, `.glass`, `.btn-primary/secondary` |
|
||||||
|
| `extension/src/popup/components/unlock.ts` | Logo lockup, glass card, primary unlock button |
|
||||||
|
| `extension/src/popup/components/types/login.ts` | Add `surface: 'popup' \| 'fullscreen'` param; column wrapping when fullscreen |
|
||||||
|
| `extension/src/setup/setup.ts` | `.surface-backdrop`, glass step cards, glass mode-picker cards, `▸` arrows |
|
||||||
|
| `extension/src/setup/setup.html` | Body wrapper class |
|
||||||
|
| `extension/src/vault/vault.css` | Patina tokens, glass form sections, form-grid, sticky save bar, header treatment |
|
||||||
|
| `extension/src/vault/vault.ts` | Header subtitle dirty-state subscriber, surface flag passed to login renderer |
|
||||||
|
| `extension/src/vault/components/*.ts` | Glass class on form panels |
|
||||||
|
|
||||||
|
The login renderer needs to know which surface it's rendering into (popup vs fullscreen). Add an optional `surface: 'popup' | 'fullscreen'` parameter to `renderForm()`. Default to `popup` to preserve existing behavior.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Per-area tests using existing `vitest` + `happy-dom` setup:
|
||||||
|
|
||||||
|
1. **Palette migration test:** computed style on `.btn-primary` resolves to `#a88a4a`.
|
||||||
|
2. **Layout test:** mount login form with `surface: 'fullscreen'`, assert grid layout, Identity / Credentials columns contain expected fields.
|
||||||
|
3. **Stack-down test:** simulate viewport ≤720px, assert single-column.
|
||||||
|
4. **Dirty subtitle test:** mount form, simulate input, assert subtitle text changes.
|
||||||
|
5. **Sticky bar test:** assert save bar exists, position style, save button validity.
|
||||||
|
6. **Glass class application:** unlock card, setup step card, form panels all get `.glass` class.
|
||||||
|
7. **Arrow glyph test:** all "next" buttons render `▸` (no `→`).
|
||||||
|
8. **Other-type non-regression:** mount `secure_note`, assert single-column layout.
|
||||||
|
9. **Logo regression:** snapshot test on `relicario-logo.svg` defs/colors.
|
||||||
|
|
||||||
|
Manual QA pass per surface with the rebuilt extension loaded in Chrome and Firefox.
|
||||||
|
|
||||||
|
## CLI parity
|
||||||
|
|
||||||
|
This phase is purely visual / layout-shaped. No CLI counterpart. The CLI already accepts all login fields as flags (`--title`, `--username`, `--password`, etc.).
|
||||||
|
|
||||||
|
## Out of scope / deferred
|
||||||
|
|
||||||
|
- Two-column layout for non-login types.
|
||||||
|
- User-resizable column widths.
|
||||||
|
- Animated transitions on subtitle text change (snap is fine).
|
||||||
|
- Functional ⌘+S keyboard shortcut — arrives with Phase 3 keymap. The hint is a visual label until then.
|
||||||
|
- Diff view / form-level "you changed N fields" indicator.
|
||||||
|
- Light theme.
|
||||||
218
docs/superpowers/specs/2026-05-02-relay-server-design.md
Normal file
218
docs/superpowers/specs/2026-05-02-relay-server-design.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Relay Server Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-02
|
||||||
|
**Status:** Approved
|
||||||
|
**Scope:** Dev tooling — not shipped in any product artifact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Multi-agent development lifts (PM + Dev-A + Dev-B in parallel Claude Code sessions) require passing status updates, questions, and directives between terminals. Today the user manually copies and pastes every message block. This is error-prone, breaks flow, and scales poorly as lift complexity grows.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A lightweight MCP server running on localhost that gives all three Claude Code sessions native tools to post and read messages. The user stops being the message bus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/relay/
|
||||||
|
├── package.json # private, not published; single dep: @modelcontextprotocol/sdk
|
||||||
|
├── tsconfig.json
|
||||||
|
├── server.ts # MCP SSE server entry point (~150 lines)
|
||||||
|
├── queue.ts # in-memory queue logic (~50 lines)
|
||||||
|
├── queue.test.ts # Node built-in test runner
|
||||||
|
└── start.sh # launcher script
|
||||||
|
```
|
||||||
|
|
||||||
|
Added to root `.gitignore`: `tools/relay/node_modules/`, `tools/relay/dist/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **Runtime:** Node.js (v25, already installed at `/usr/bin/node`)
|
||||||
|
- **Package manager:** npm (bun has known compat gaps with the MCP SDK's SSE transport)
|
||||||
|
- **Dependencies:** `@modelcontextprotocol/sdk`, `tsx` (devDependency, runs TypeScript directly — no compile step)
|
||||||
|
- **Transport:** SSE (`SSEServerTransport` from the SDK handles the HTTP layer — no Express or Hono needed)
|
||||||
|
- **Port:** `7331` (hardcoded; easy to remember, unlikely to collide)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Queue model
|
||||||
|
|
||||||
|
Three named inboxes: `pm`, `dev-a`, `dev-b`. Each is a FIFO array in memory.
|
||||||
|
|
||||||
|
Message shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RelayMessage {
|
||||||
|
id: string; // uuid v4
|
||||||
|
from: string; // sender role name
|
||||||
|
to: string; // recipient role name
|
||||||
|
kind: "status" | "question" | "directive" | "free";
|
||||||
|
body: string; // freeform, typically the existing markdown block format
|
||||||
|
ts: string; // ISO 8601
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kind` maps to the existing coordination protocol:
|
||||||
|
|
||||||
|
| kind | existing block |
|
||||||
|
|-------------|-----------------------------|
|
||||||
|
| `status` | `## STATUS UPDATE — DEV-*` |
|
||||||
|
| `question` | `## QUESTION TO PM — DEV-*` |
|
||||||
|
| `directive` | `## DIRECTIVE TO DEV-*` |
|
||||||
|
| `free` | ad-hoc / unstructured |
|
||||||
|
|
||||||
|
Messages are **consume-once**: `read_messages` drains the inbox. There is no persistence — if the server restarts mid-lift, in-flight messages are lost. Acceptable for a dev tool; agents re-send on reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP tool surface
|
||||||
|
|
||||||
|
All three tools are exposed to every connected session.
|
||||||
|
|
||||||
|
### `post_message`
|
||||||
|
|
||||||
|
```
|
||||||
|
post_message(from: "pm"|"dev-a"|"dev-b", to: "pm"|"dev-a"|"dev-b", kind: "status"|"question"|"directive"|"free", body: string) → { id: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushes one message onto the target's inbox. Returns the assigned message id. Errors if `to` or `from` is not a known role. Agents declare their own identity via `from` — the kickoff prompt tells each agent its role name.
|
||||||
|
|
||||||
|
### `read_messages`
|
||||||
|
|
||||||
|
```
|
||||||
|
read_messages(for: "pm"|"dev-a"|"dev-b") → RelayMessage[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pops and returns all pending messages for that recipient, in FIFO order. After this call the inbox is empty.
|
||||||
|
|
||||||
|
### `list_pending`
|
||||||
|
|
||||||
|
```
|
||||||
|
list_pending(for: "pm"|"dev-a"|"dev-b") → { count: number, kinds: string[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns count and kind breakdown of pending messages without consuming them. Lets an agent cheaply check "do I have anything to act on?" before committing to a `read_messages` call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server terminal output
|
||||||
|
|
||||||
|
Every `post_message` call prints a one-liner to stdout in the dedicated relay terminal:
|
||||||
|
|
||||||
|
```
|
||||||
|
[14:32:01] dev-b → pm [status] "Task P4 DONE, last commit abc1234..."
|
||||||
|
[14:33:15] pm → dev-b [directive] "PROCEED to task B1"
|
||||||
|
```
|
||||||
|
|
||||||
|
This log is the operational value of keeping the server in a dedicated terminal rather than backgrounding it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Launcher script (`start.sh`)
|
||||||
|
|
||||||
|
`start.sh` accepts one optional flag:
|
||||||
|
|
||||||
|
| Flag | Behavior |
|
||||||
|
|------------|----------|
|
||||||
|
| *(default)*| `--manual` mode: prints three labeled prompt blocks (one per role) for copy-paste into fresh Claude Code sessions, then starts the server in the foreground |
|
||||||
|
| `--tmux` | Creates a new tmux window with four panes: relay server + PM + Dev-A + Dev-B, each pre-loaded with its kickoff command |
|
||||||
|
| `--kitty` | Same layout using kitty's `launch --new-tab` / `--new-window` |
|
||||||
|
|
||||||
|
Execution order (all modes):
|
||||||
|
|
||||||
|
1. `cd tools/relay && npm install --silent` (no-op if `node_modules` is current)
|
||||||
|
2. Print the session snippet (copy-paste blocks or multiplexer launch)
|
||||||
|
3. Foreground `npx tsx server.ts` — the terminal that ran `start.sh` becomes the relay terminal; no compile step needed
|
||||||
|
|
||||||
|
Port-already-in-use check before step 3: if `:7331` is bound, print `relay already running? kill it with: kill $(lsof -ti:7331)` and exit 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude Code configuration
|
||||||
|
|
||||||
|
Add to project `.claude/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is project-scoped — the relay tools only appear in Relicario Claude Code sessions. When the server is not running, Claude Code shows a yellow MCP connection warning but does not break. Agents gracefully fall back to asking the user to relay manually (existing behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kickoff prompt changes
|
||||||
|
|
||||||
|
One paragraph added near the top of each coordination prompt (`v0.5.0-pm-prompt.md`, `v0.5.0-dev-a-prompt.md`, `v0.5.0-dev-b-prompt.md` as template):
|
||||||
|
|
||||||
|
> **Relay server:** A message-bus MCP server is running. You have three tools: `post_message(to, kind, body)`, `read_messages(for)`, `list_pending(for)`. Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task call `read_messages(for="<your-role>")`. After emitting any status, question, or directive block, call `post_message` with `kind` set to the block type and `body` set to the formatted block.
|
||||||
|
|
||||||
|
The `multi-agent-kickoff` skill is updated to:
|
||||||
|
- Remind the user to run `tools/relay/start.sh` before opening the three sessions
|
||||||
|
- Inject the relay paragraph automatically into every generated kickoff prompt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Unknown `to` in `post_message` | MCP error returned; message not queued |
|
||||||
|
| Server crash / restart | In-flight messages lost; agent re-sends |
|
||||||
|
| Port 7331 in use at startup | Startup exits 1 with a kill hint |
|
||||||
|
| Session connects before server starts | Claude Code shows MCP warning; agent falls back to manual relay |
|
||||||
|
|
||||||
|
No authentication. This is localhost-only, single-machine, dev-tool use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`queue.test.ts` using Node's built-in `node:test` runner. No extra test dep.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- `post_message` + `read_messages` roundtrip (single and multiple messages)
|
||||||
|
- Consume-once: second `read_messages` on same inbox returns empty
|
||||||
|
- `list_pending` does not drain inbox
|
||||||
|
- FIFO ordering across multiple senders to the same inbox
|
||||||
|
- Unknown recipient returns an error
|
||||||
|
|
||||||
|
No integration test against the MCP SSE transport — that is the SDK's responsibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-level README (`docs/superpowers/MULTI-AGENT.md`)
|
||||||
|
|
||||||
|
A durable reference document covering the whole development paradigm — not the relay server specifically, but the entire three-terminal workflow that the relay server enables. Lives in `docs/superpowers/` alongside the specs and plans it describes.
|
||||||
|
|
||||||
|
**Contents:**
|
||||||
|
|
||||||
|
1. **Overview** — the PM/Dev-A/Dev-B pattern: why three terminals, what each role owns, what the user's job is (authorize merges, resolve escalations)
|
||||||
|
2. **Starting a lift** — prerequisites checklist, then: `tools/relay/start.sh` → three sessions → paste kickoff prompts
|
||||||
|
3. **Coordination protocol reference** — the four message kinds (`status`, `question`, `directive`, `free`), when each is used, what a well-formed body looks like
|
||||||
|
4. **Using the relay tools** — `post_message`, `read_messages`, `list_pending` with one-liner examples
|
||||||
|
5. **If the relay server isn't running** — fallback to manual copy-paste; the coordination protocol still works, just with the user as bus
|
||||||
|
6. **Generating kickoff prompts** — point to the `multi-agent-kickoff` skill; note that the skill injects the relay paragraph automatically
|
||||||
|
7. **Ending a lift** — PM emits MERGE-APPROVED, devs push branches, user authorizes merges, Ctrl-C the relay terminal
|
||||||
|
|
||||||
|
This README is written for future-you opening the repo six months from now, not for the current lift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
- Not a product feature — never bundled with the extension or CLI
|
||||||
|
- Not persistent — no SQLite, no file queue, in-memory only
|
||||||
|
- Not authenticated — localhost dev tool, no threat model
|
||||||
|
- Not a general-purpose message bus — three hardcoded roles, no dynamic registration
|
||||||
340
docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md
Normal file
340
docs/superpowers/specs/2026-05-02-v0.5.0-polish-harden-design.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# v0.5.0 — Polish + Harden — Design
|
||||||
|
|
||||||
|
Date: 2026-05-02
|
||||||
|
Status: Draft
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
v0.5.0 is a "polish + harden" bundle: a security-vulnerability fix, two
|
||||||
|
hardening follow-ups, two confirmed bugs, and four UX improvements.
|
||||||
|
No new functional features. Functional work — LastPass import, recovery
|
||||||
|
QR, Plan 1C-γ, Fullscreen Phases 3/4 — proceeds on parallel plans.
|
||||||
|
|
||||||
|
The anchor item is a **HIGH-severity authentication bypass** in
|
||||||
|
`relicario-server`'s pre-receive hook (S1): both the registered-device
|
||||||
|
check and the revocation check are unimplemented. Until this lands, the
|
||||||
|
device-auth system is a no-op.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Restore device-auth integrity: only registered, non-revoked signing
|
||||||
|
keys can push to the vault repo.
|
||||||
|
2. Close two minor hardening gaps surfaced during the security review.
|
||||||
|
3. Fix two user-visible bugs (strength-meter desync after generate,
|
||||||
|
raw error codes in the fullscreen tab).
|
||||||
|
4. Deliver four UX improvements that are polish-shaped, not feature-shaped.
|
||||||
|
5. Tag v0.5.0 with no known security vulnerabilities or visible-error-code
|
||||||
|
leaks.
|
||||||
|
|
||||||
|
## Scope Map
|
||||||
|
|
||||||
|
| Bucket | Item | Plan |
|
||||||
|
|---|---|---|
|
||||||
|
| Security | S1: Pre-receive hook fix | A |
|
||||||
|
| Security | S2: Tar archive path-traversal hardening | A |
|
||||||
|
| Security | S3: `RELICARIO_*` env-var audit | A |
|
||||||
|
| Cleanup | C1: Stale feature branch prune | A |
|
||||||
|
| Bugs | B1: Strength meter desync after regenerate | B |
|
||||||
|
| Bugs | B2: `vault_locked` raw error code in fullscreen tab | B (subsumed by P4) |
|
||||||
|
| UX | P1: Password coloring | B |
|
||||||
|
| UX | P2: Setup-wizard completion → fullscreen vault tab | B |
|
||||||
|
| UX | P3: Form-layout 2-col → full-width transition | B |
|
||||||
|
| UX | P4: Error-message audit (snake_case codes → friendly copy) | B |
|
||||||
|
|
||||||
|
Two plans split by language/blast-radius:
|
||||||
|
- **Plan A** = Rust + docs (server, CLI, env-var audit, branch cleanup)
|
||||||
|
- **Plan B** = Extension UX (TypeScript + CSS)
|
||||||
|
|
||||||
|
The plans share no files and can ship independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan A — Security + Cleanup
|
||||||
|
|
||||||
|
### S1. Pre-Receive Hook Fix (anchor)
|
||||||
|
|
||||||
|
**Problem.** `crates/relicario-server/src/main.rs:36-81`: `verify_commit`
|
||||||
|
loads `devices` and `revoked` from the repo at the commit, then drops
|
||||||
|
both on the floor. It runs `git verify-commit --raw` and accepts on
|
||||||
|
`GOODSIG` / `Good signature` regardless of which key produced the
|
||||||
|
signature. This means:
|
||||||
|
|
||||||
|
- An attacker who never registered a device can push commits signed
|
||||||
|
with any GPG key that the server's keyring/allowed-signers happens
|
||||||
|
to trust (or none at all if the server has no SSH allowed-signers
|
||||||
|
configured).
|
||||||
|
- A revoked device's signing key continues to authenticate after
|
||||||
|
`relicario device revoke` is run — the `revoked.json` write is
|
||||||
|
cosmetic.
|
||||||
|
|
||||||
|
**Fix.** Implement the verification logic per the design spec
|
||||||
|
(`docs/superpowers/specs/2026-05-02-device-authentication-design.md:284-300`):
|
||||||
|
|
||||||
|
1. Build a temporary allowed-signers file from `devices.json` entries
|
||||||
|
loaded at the commit. Pass it to `git verify-commit` via
|
||||||
|
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile
|
||||||
|
GIT_CONFIG_VALUE_0=<tmpfile>` so we don't mutate global git config.
|
||||||
|
2. Parse the signing-key fingerprint out of `git verify-commit --raw`
|
||||||
|
output (the fingerprint appears on the `Good "git" signature for …
|
||||||
|
with … key SHA256:…` line for SSH).
|
||||||
|
3. Reject if the fingerprint is not in `devices.json` at the commit.
|
||||||
|
4. Reject if the fingerprint is in `revoked.json` AND
|
||||||
|
`commit_timestamp >= revoked_at`. The historical-commit case
|
||||||
|
(timestamp < `revoked_at`) is allowed so old commits survive
|
||||||
|
revocation.
|
||||||
|
5. Use the commit's **committer date** (`GIT_COMMITTER_DATE`,
|
||||||
|
accessible via `git show -s --format=%ct`) — *not* the current wall
|
||||||
|
clock and not the author date — as `commit_timestamp`. Committer
|
||||||
|
date is when the signature was applied; author date is when work
|
||||||
|
was originally written and could be from before revocation even
|
||||||
|
for a malicious replay.
|
||||||
|
|
||||||
|
**Acceptance.**
|
||||||
|
- An integration test that registers a device, revokes it, signs a
|
||||||
|
commit with the revoked key dated AFTER `revoked_at`, and confirms
|
||||||
|
the hook exits non-zero.
|
||||||
|
- An integration test that signs a commit with a key that was never
|
||||||
|
registered and confirms the hook exits non-zero.
|
||||||
|
- An integration test that signs a commit with a registered, non-revoked
|
||||||
|
key and confirms the hook exits zero.
|
||||||
|
- An integration test that signs a commit with a revoked key dated
|
||||||
|
BEFORE `revoked_at` (historical case) and confirms the hook exits zero.
|
||||||
|
- The dead `let _ = &revoked;` line is gone.
|
||||||
|
|
||||||
|
### S2. Tar Archive Path-Traversal Hardening
|
||||||
|
|
||||||
|
**Problem.** `crates/relicario-cli/src/main.rs:1722` unpacks the
|
||||||
|
bundled `git_archive` from a `.relbak` via `tar::Archive::unpack`,
|
||||||
|
trusting the `tar` crate's defaults. A malicious `.relbak` could
|
||||||
|
contain entries with `..` components or absolute paths.
|
||||||
|
|
||||||
|
**Fix.** Iterate entries explicitly:
|
||||||
|
|
||||||
|
- Reject any entry whose path contains a `..` component, an absolute
|
||||||
|
prefix, or a Windows drive letter.
|
||||||
|
- Reject symlinks and hardlinks (we never write either inside `.git/`
|
||||||
|
on restore).
|
||||||
|
- Cap total uncompressed size at 100× the compressed `git_archive`
|
||||||
|
size or 1 GiB, whichever is lower (defends against tar bombs).
|
||||||
|
- Write each entry under `target.join(".git")` only after the path
|
||||||
|
resolves *inside* that directory.
|
||||||
|
|
||||||
|
**Acceptance.**
|
||||||
|
- Unit test with a hand-crafted tar containing `../../etc/passwd` —
|
||||||
|
restore bails with "path traversal blocked".
|
||||||
|
- Unit test with a symlink entry — restore bails.
|
||||||
|
- Unit test with a 1 TiB sparse entry — restore bails on the size cap.
|
||||||
|
- Existing valid-restore test still passes.
|
||||||
|
|
||||||
|
### S3. `RELICARIO_*` Env-Var Audit
|
||||||
|
|
||||||
|
**Problem.** Three env vars are not gated by `cfg(debug_assertions)`:
|
||||||
|
- `RELICARIO_IMAGE` (`crates/relicario-cli/src/session.rs:125`) — reference-image override path.
|
||||||
|
- `RELICARIO_NO_GROUPS_CACHE` (`crates/relicario-cli/src/helpers.rs:103`) — disables the groups cache.
|
||||||
|
- `RELICARIO_GITEA_URL` (`crates/relicario-cli/src/main.rs:2298`) — Gitea base URL fallback.
|
||||||
|
|
||||||
|
Spot-check confirmed these look intentional, but they're undocumented
|
||||||
|
and `RELICARIO_NO_GROUPS_CACHE` is more of a dev escape hatch than
|
||||||
|
production config.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
- Document each env var in `docs/SECURITY.md` and the relevant
|
||||||
|
`--help` text.
|
||||||
|
- Move `RELICARIO_NO_GROUPS_CACHE` under `cfg(debug_assertions)` —
|
||||||
|
it's a dev tool, not a user knob.
|
||||||
|
- Leave `RELICARIO_IMAGE` and `RELICARIO_GITEA_URL` as user-facing
|
||||||
|
config; audit each call site to confirm no untrusted-input-flows-into-path
|
||||||
|
cases.
|
||||||
|
|
||||||
|
**Acceptance.**
|
||||||
|
- `docs/SECURITY.md` lists every `RELICARIO_*` env var with purpose
|
||||||
|
and trust assumption.
|
||||||
|
- `RELICARIO_NO_GROUPS_CACHE` is no-op in `cargo build --release`.
|
||||||
|
|
||||||
|
### C1. Stale Feature Branch Prune
|
||||||
|
|
||||||
|
**Problem.** Six local branches with no merged history beyond what's
|
||||||
|
already in main: `feature/fullscreen-ux-phase-2a`,
|
||||||
|
`feature/typed-items-1a-rust-core`, `feature/typed-items-1c-alpha`,
|
||||||
|
`feature/typed-items-1c-beta1`, `feature/typed-items-1c-beta2`.
|
||||||
|
|
||||||
|
**Fix.** Verify each is fully merged into main (`git branch --merged
|
||||||
|
main`), then delete locally only — no remote branch operations. Plan
|
||||||
|
execution will surface the list and prompt the user before running
|
||||||
|
`git branch -D` per branch (per project rule on git-destructive ops).
|
||||||
|
|
||||||
|
**Acceptance.** `git branch` shows only `main` and active feature
|
||||||
|
branches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan B — Extension UX
|
||||||
|
|
||||||
|
### B1. Strength Meter Desync After Regenerate
|
||||||
|
|
||||||
|
**Problem.** Confirmed in screenshot from 2026-05-02: clicking the
|
||||||
|
regenerate button on a login form's password field rerolls a
|
||||||
|
20-character mixed-class password (e.g., `sCMtTJkF%GN^mF#-N6D%`) but
|
||||||
|
the strength meter still reports `~10^1 guesses — trivially crackable`.
|
||||||
|
The rated string is stale — likely whatever was in the field before
|
||||||
|
the reroll, or empty.
|
||||||
|
|
||||||
|
The meter listens to `input` events on the password field
|
||||||
|
(`extension/src/shared/form-affordances/password-tools.ts:65`). The
|
||||||
|
regenerate handler sets `input.value = newPassword` programmatically,
|
||||||
|
which **does not fire** `input` events in standard DOM behavior.
|
||||||
|
|
||||||
|
**Fix.** In the regenerate handler (search for the orange-spinner
|
||||||
|
button click), after assigning `input.value`, dispatch an
|
||||||
|
`InputEvent('input', { bubbles: true })`. Confirm by re-rating
|
||||||
|
inside the handler if needed.
|
||||||
|
|
||||||
|
**Acceptance.**
|
||||||
|
- Vitest: simulate regenerate click, assert that `input` event is
|
||||||
|
dispatched and meter calls `scheduleRate` with the new value.
|
||||||
|
- Manual: regenerate a password on the login form, confirm meter
|
||||||
|
jumps to "strong" with appropriate `guesses_log10`.
|
||||||
|
|
||||||
|
### P4. Error-Message Audit (subsumes B2)
|
||||||
|
|
||||||
|
**Problem.** Snake_case error codes leak straight into the fullscreen
|
||||||
|
tab and other surfaces:
|
||||||
|
|
||||||
|
- `vault_locked` shown as a red string in the new-login form
|
||||||
|
(screenshot 2026-05-02).
|
||||||
|
- ~20 call sites in `extension/src/service-worker/router/` return
|
||||||
|
`{ ok: false, error: 'vault_locked' }` and similar.
|
||||||
|
|
||||||
|
The popup has a one-off mapping at `extension/src/popup/popup.ts:147`
|
||||||
|
(`/vault_locked/i.test(err)` → unlock prompt), but the fullscreen tab
|
||||||
|
has no equivalent.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
1. Build a single `ERROR_COPY` map keyed by error code, returning
|
||||||
|
`{ title: string; body: string; cta?: { label: string; action: () => void } }`.
|
||||||
|
Place at `extension/src/shared/error-copy.ts`.
|
||||||
|
2. Audit call sites in `extension/src/service-worker/router/` for all
|
||||||
|
error codes; ensure each has a mapping.
|
||||||
|
3. Replace the regex in `popup.ts:147` and the raw-error rendering in
|
||||||
|
the fullscreen tab with `lookupErrorCopy(code)`.
|
||||||
|
4. For `vault_locked` specifically: the CTA is "Unlock vault" → opens
|
||||||
|
the popup unlock view (or in the fullscreen tab, the inline unlock
|
||||||
|
form).
|
||||||
|
|
||||||
|
**Acceptance.**
|
||||||
|
- No raw `snake_case` strings render in any UI surface for any error
|
||||||
|
return path. Verified by grep + manual trigger of each error code.
|
||||||
|
- `vault_locked` in the fullscreen tab shows friendly copy + an
|
||||||
|
"Unlock vault" button that works.
|
||||||
|
- Vitest: enumerate every distinct `error: '...'` string literal
|
||||||
|
found in `extension/src/service-worker/router/` via grep, assert
|
||||||
|
each is a key in `ERROR_COPY`. Generated test or build-time check
|
||||||
|
preferred over a static snapshot so it can't drift.
|
||||||
|
|
||||||
|
### P1. Password Coloring
|
||||||
|
|
||||||
|
**Problem / Fix.** Implement per existing spec at
|
||||||
|
`docs/superpowers/specs/2026-05-01-password-coloring-design.md` and plan
|
||||||
|
at `docs/superpowers/plans/2026-05-01-password-coloring.md`. No design
|
||||||
|
changes; pulled into v0.5.0 because it's polish-flavored and small.
|
||||||
|
|
||||||
|
**Acceptance.** Per existing plan's acceptance criteria.
|
||||||
|
|
||||||
|
### P2. Setup-Wizard Completion → Fullscreen Vault Tab
|
||||||
|
|
||||||
|
**Problem.** Setup wizard currently routes to the popup item-list view
|
||||||
|
on completion. The user wants to land in the fullscreen vault tab —
|
||||||
|
the experience reads as more "real software" and avoids the sub-300px
|
||||||
|
popup width on first run.
|
||||||
|
|
||||||
|
**Fix.** In the setup wizard's terminal step, after the vault is
|
||||||
|
created and the device is registered:
|
||||||
|
|
||||||
|
1. Open the fullscreen vault tab via `chrome.tabs.create({ url:
|
||||||
|
chrome.runtime.getURL('vault.html') })`.
|
||||||
|
2. Close the setup tab (or the popup, depending on entry point).
|
||||||
|
|
||||||
|
**Acceptance.**
|
||||||
|
- Manual: complete the new-vault setup flow; vault tab opens, setup
|
||||||
|
tab closes.
|
||||||
|
- Manual: complete the attach-existing flow; same behavior.
|
||||||
|
- Vitest: assert `chrome.tabs.create` is called with the vault URL on
|
||||||
|
successful setup completion.
|
||||||
|
|
||||||
|
### P3. Form-Layout 2-col → Full-Width Transition
|
||||||
|
|
||||||
|
**Problem.** Screenshot 2026-05-02 shows the new-login form in the
|
||||||
|
fullscreen tab: the IDENTITY and CREDENTIALS cards sit in a 2-column
|
||||||
|
grid with a max-width that stops well short of viewport edge, but the
|
||||||
|
Notes textarea, custom-fields disclosure, and attachments disclosure
|
||||||
|
below all stretch full-width. The visual rhythm breaks at the
|
||||||
|
transition — the lower sections look like a different page.
|
||||||
|
|
||||||
|
**Fix.** Two candidate approaches; pick at implementation time:
|
||||||
|
|
||||||
|
- **A.** Constrain the lower sections to the same max-width and
|
||||||
|
horizontal alignment as the cards. They become a third "row" in the
|
||||||
|
same column system. Simpler, less code.
|
||||||
|
- **B.** Wrap the lower sections in a card matching the upper cards'
|
||||||
|
visual treatment. Notes-as-card, custom-fields-as-card,
|
||||||
|
attachments-as-card. More work, more visual consistency, but might
|
||||||
|
feel heavier.
|
||||||
|
|
||||||
|
Recommended: **A** for v0.5.0 — minimal CSS change, immediate
|
||||||
|
correctness. Revisit B in Phase 3 if the user still feels the layout
|
||||||
|
is off.
|
||||||
|
|
||||||
|
**Acceptance.**
|
||||||
|
- The new-login form (and all item-type forms — login, secure note,
|
||||||
|
identity, card, key, document, totp) renders with consistent
|
||||||
|
horizontal alignment from top of form to bottom.
|
||||||
|
- Manual: viewport at 1920×1080, 1440×900, 1024×768, and 768×1024;
|
||||||
|
no jarring width transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
| Layer | Tests |
|
||||||
|
|---|---|
|
||||||
|
| Rust unit (`relicario-server`) | S1 acceptance set (4 scenarios) |
|
||||||
|
| Rust unit (`relicario-core`) | S2 path-traversal scenarios (3 scenarios) |
|
||||||
|
| CLI integration | S2 happy-path restore unchanged |
|
||||||
|
| Vitest (extension) | B1 input-event dispatch; P4 ERROR_COPY snapshot; P2 tabs.create call |
|
||||||
|
| Manual | P3 viewport sweep; B2/P4 trigger each error code; S1 hook setup on a real Gitea instance; C1 branch cleanup confirmation |
|
||||||
|
|
||||||
|
Existing tests stay green. No regression budget.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
Listed for clarity; each tracked separately:
|
||||||
|
|
||||||
|
- LastPass import (`docs/superpowers/plans/2026-04-29-relicario-lastpass-import.md`)
|
||||||
|
- Recovery QR + entropy floor (`docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md`)
|
||||||
|
- Plan 1C-γ (attachments + Document + trash UI)
|
||||||
|
- Fullscreen Phase 3 (shell) and Phase 4 (palette)
|
||||||
|
- Pre-v0.3.0 manual test walk (`docs/test-checklists/2026-04-27-pre-v0.3.0-audit.md`) — that's a v0.3.0 release gate, not a v0.5.0 item
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
1. Plan A and Plan B can run in parallel (no shared files).
|
||||||
|
2. Inside Plan A: S1 first (unblocks the security claim), then S2,
|
||||||
|
then S3, then C1.
|
||||||
|
3. Inside Plan B: P4 first (unblocks B2 and surfaces all error codes
|
||||||
|
centrally), then B1, then P1, then P3, then P2 (P2 is end-to-end
|
||||||
|
and benefits from earlier polish).
|
||||||
|
4. Both plans merge to main; tag `v0.5.0` after both PRs are green.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **S1 SSH allowed-signers parsing.** `git verify-commit --raw`
|
||||||
|
output format for SSH signatures differs slightly from GPG; the
|
||||||
|
fingerprint-extraction regex needs unit coverage against real
|
||||||
|
output. Mitigation: capture sample outputs from a test repo and
|
||||||
|
check them into the test fixtures.
|
||||||
|
- **P3 layout regressions.** Constraining the lower sections may
|
||||||
|
affect the textarea behavior at small widths. Mitigation: viewport
|
||||||
|
sweep in acceptance.
|
||||||
|
- **C1 accidental deletion.** Plan execution must `git branch
|
||||||
|
--merged main` filter and prompt before each `-D`. The
|
||||||
|
no-destructive-without-asking project rule applies.
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# v0.5.x — UX Polish, Settings Redesign & Recovery QR — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-03
|
||||||
|
**Status:** Draft
|
||||||
|
**Target:** v0.5.1 / next release train
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three parallel streams building on the v0.5.0 base:
|
||||||
|
|
||||||
|
- **Stream A — Fullscreen + popup layout polish** — fullscreen vault tab gets a new 3-column layout (sidebar with type-category nav, full-width list, slide-in detail drawer); popup gets a polished type-picker; glyph additions; toast system; empty states.
|
||||||
|
- **Stream B — Settings UX redesign** — replace the current flat settings dump with a left-nav sectioned settings page; Security section with trusted-devices and Recovery QR integration.
|
||||||
|
- **Stream C — Recovery QR + setup wizard** — implement the recovery QR cryptographic feature (Rust core + WASM); integrate into the setup wizard's final step; wire into the vault-tab Security settings section.
|
||||||
|
|
||||||
|
Streams A and B share no files with Stream C (Rust/WASM). A and B share only `glyphs.ts` and `styles.css`; all other files are disjoint. All three can run in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stream A — Fullscreen + Popup Layout Polish
|
||||||
|
|
||||||
|
### A1. Fullscreen vault tab — 3-column layout
|
||||||
|
|
||||||
|
**Current state.** `extension/src/vault/vault.ts` renders a fixed sidebar (~220px) with brand, search, item list, and bottom nav buttons. Clicking `+ new item` navigates to the type-picker. The main pane shows the selected item in a single-column layout.
|
||||||
|
|
||||||
|
**New layout.**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬──────────────────────────┬──────────────────────────┐
|
||||||
|
│ sidebar │ full-width list │ detail drawer (440px) │
|
||||||
|
│ (200px) │ (flex: 1) │ slides in on row click │
|
||||||
|
└─────────────┴──────────────────────────┴──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sidebar changes:**
|
||||||
|
- Replaces the current flat item list with **type-category nav**: all items are listed by section (Logins N, Secure Notes N, Cards N, Identities N, TOTP N, Keys N, Documents N) plus an "All items" entry at the top.
|
||||||
|
- Search bar stays above the category list.
|
||||||
|
- Bottom nav buttons remain (+ new item, ▦ trash, ⌬ devices, ⚙ settings, ⏻ lock) — the `+ new item` button triggers the bottom sheet (see A3).
|
||||||
|
- `⧉` replaces the current `⤴` pop-out button in the **popup toolbar only** — it stays in the popup toolbar and is not added to the fullscreen sidebar (you're already there).
|
||||||
|
|
||||||
|
**Full-width list:**
|
||||||
|
- Each row: 32px type icon (rounded, gold-tinted on selection) + title (13px) + subtitle (URL or type description, 11px muted) + `last-modified` age (10px dim, right-aligned).
|
||||||
|
- Clicking a row: highlights the row and slides in the detail drawer from the right. The list narrows to accommodate the 440px drawer — flex layout handles this naturally.
|
||||||
|
- Active row stays highlighted while drawer is open.
|
||||||
|
|
||||||
|
**Detail drawer (440px):**
|
||||||
|
- Header: type pill (e.g. `LOGIN`) left, action buttons right (`edit`, `history`, `copy pwd` where applicable), `✕` close.
|
||||||
|
- Body: title (18-20px bold) + subtitle (URL/description, muted), then a **2-column field grid** for sibling fields (username/password, first/last name, number/expiry, etc.). Full-width spans for URL, notes, address, and any field without a natural pair.
|
||||||
|
- Close (`✕` or Esc): drawer slides out, list returns to full width.
|
||||||
|
- At ≤ 720px viewport: drawer pushes full-page (list hidden), back breadcrumb `← <Section>` navigates back.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `extension/src/vault/vault.ts` — full layout rewrite (sidebar list → category nav, main pane wiring, drawer state)
|
||||||
|
- `extension/src/vault/vault.css` — layout rules for 3-column, drawer, list rows, responsive breakpoint
|
||||||
|
|
||||||
|
### A2. Fullscreen vault tab — "new item" bottom sheet
|
||||||
|
|
||||||
|
**Current state.** Clicking `+ new item` in the sidebar sets `state.newType = null` and calls `renderPane()` which renders the type-picker inline in the main pane.
|
||||||
|
|
||||||
|
**New behaviour.** A bottom sheet slides up from the bottom edge of the **main pane** (pane-only scrim — sidebar stays interactive).
|
||||||
|
|
||||||
|
- Sheet structure: drag handle, "New item — choose type" label, 7-item type grid (Login, Secure Note, TOTP, Card, Identity, SSH/API Key, Document) as cards with large glyph (28px), name (11px muted). Selected type border turns gold on hover.
|
||||||
|
- Clicking a type: sheet closes, main pane renders the add form for that type.
|
||||||
|
- Dismissing (Esc, click scrim, `✕`): sheet closes, main pane returns to previous state.
|
||||||
|
- Scrim covers the main pane only (not the sidebar). Sidebar nav remains clickable.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `extension/src/vault/vault.ts` — sheet trigger, render, dismiss logic
|
||||||
|
- `extension/src/vault/vault.css` — sheet, scrim, type-card styles
|
||||||
|
|
||||||
|
### A3. Popup — polished type-picker page
|
||||||
|
|
||||||
|
**Current state.** `+ new` button in the popup toolbar navigates directly to the `add` route. `renderItemForm` is called with `state.newType = null`, which presumably renders a type picker inline.
|
||||||
|
|
||||||
|
**New behaviour.** Keep the current navigation model (navigate to `add` route) but upgrade the type-picker page:
|
||||||
|
- Back arrow + "New item" title in the search-bar row (replacing search input).
|
||||||
|
- 2-column grid of type cards: icon (glyph, 20px), name (12px bold), description (10px muted). E.g. "Login / Username + password", "TOTP / 2FA token".
|
||||||
|
- Glyphs not emoji for type icons (use the per-type glyph table from A5).
|
||||||
|
- `Esc` navigates back to the list.
|
||||||
|
- Keyhint bar updates to show `Esc back`.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `extension/src/popup/components/item-list.ts` — `+ new` button label/glyph, keyhint
|
||||||
|
- `extension/src/popup/components/item-form.ts` (or wherever the type picker lives) — card layout, glyphs
|
||||||
|
|
||||||
|
### A4. Glyphs
|
||||||
|
|
||||||
|
Add to `extension/src/shared/glyphs.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const GLYPH_VAULT_TAB = '⧉'; // pop-out to fullscreen vault tab (replaces ⤴)
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the inline `⤴` from `extension/src/popup/components/item-list.ts:69` and replace with `GLYPH_VAULT_TAB`.
|
||||||
|
|
||||||
|
### A5. Item row type icons
|
||||||
|
|
||||||
|
The popup item list (`buildRowsHtml` in `item-list.ts`) currently renders title-only rows with no visual type anchor. Add a per-type glyph to each row using the item's `ManifestEntry.type` field:
|
||||||
|
|
||||||
|
| Type | Glyph |
|
||||||
|
|------|-------|
|
||||||
|
| login | `◉` |
|
||||||
|
| secure_note | `◫` |
|
||||||
|
| totp | `⊡` |
|
||||||
|
| card | `▭` |
|
||||||
|
| identity | `⌬` |
|
||||||
|
| key | `⊹` |
|
||||||
|
| document | `≡` |
|
||||||
|
|
||||||
|
Icon: 26×26px, rounded, `--bg-elevated` fill, gold-tinted border on active row.
|
||||||
|
|
||||||
|
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/popup/styles.css`
|
||||||
|
|
||||||
|
### A6. Empty states
|
||||||
|
|
||||||
|
Two surfaces:
|
||||||
|
|
||||||
|
1. **Popup item list, vault empty** — centered message: glyph `◈` (28px dim), "No items yet", "Press `+` to add your first item."
|
||||||
|
2. **Popup item list, search returns nothing** — centered message: glyph `⊘` (28px dim), "No results for "{query}"", "Try a shorter search term."
|
||||||
|
3. **Fullscreen list pane, section empty** — same treatment scaled for the wider pane.
|
||||||
|
|
||||||
|
**Files affected:** `extension/src/popup/components/item-list.ts`, `extension/src/vault/vault.ts`
|
||||||
|
|
||||||
|
### A7. Toast notification system
|
||||||
|
|
||||||
|
Replace the current ad-hoc `sync-status` div with a shared toast system:
|
||||||
|
|
||||||
|
- `showToast(message: string, type: 'success' | 'error' | 'info', durationMs = 2500)` in `extension/src/shared/toast.ts`.
|
||||||
|
- Toasts appear bottom-center of the popup / bottom-right of the vault tab, auto-dismiss.
|
||||||
|
- Used for: sync success/failure, copy-to-clipboard confirmation, device registration success.
|
||||||
|
|
||||||
|
**Files affected:** new `extension/src/shared/toast.ts`, `extension/src/popup/styles.css`, `extension/src/vault/vault.css`, call sites in `item-list.ts` and `vault.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stream B — Settings UX Redesign
|
||||||
|
|
||||||
|
### B1. Settings page structure
|
||||||
|
|
||||||
|
Replace the current flat settings dump (`settings.ts` + `settings-vault.ts`) with a unified settings page that renders within the fullscreen vault tab's main pane (and a compact equivalent in the popup).
|
||||||
|
|
||||||
|
**Left-nav sections:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Device
|
||||||
|
⊙ Autofill
|
||||||
|
◈ Display
|
||||||
|
Vault
|
||||||
|
◉ Security ← Recovery QR + trusted devices (replaces devices.ts nav)
|
||||||
|
↻ Generator
|
||||||
|
▦ Retention
|
||||||
|
⤓ Backup
|
||||||
|
≡ Import
|
||||||
|
```
|
||||||
|
|
||||||
|
Each section renders its content in the right panel. The left nav is 148px; content area fills the remainder.
|
||||||
|
|
||||||
|
**Device vs Vault distinction:**
|
||||||
|
- "Device" sections read/write `chrome.storage.local` (per-browser settings).
|
||||||
|
- "Vault" sections read/write encrypted `VaultSettings` (shared across devices via git).
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `extension/src/popup/components/settings.ts` — rewrite as sectioned layout
|
||||||
|
- `extension/src/popup/components/settings-vault.ts` — content moves into new section components
|
||||||
|
|
||||||
|
**Note on vault.ts:** DEV-B delivers the settings component with a stable export signature. The `⚙ settings` nav wiring in `vault.ts` is updated as part of Stream A's vault.ts rewrite. DEV-A and DEV-B must agree on the component's export signature before either lands.
|
||||||
|
|
||||||
|
### B2. Autofill section (Device)
|
||||||
|
|
||||||
|
Content replaces the current flat settings dump:
|
||||||
|
|
||||||
|
- **Capture** group: "Auto-detect logins" toggle (was checkbox); "Prompt style" select (bar / toast).
|
||||||
|
- **Blocked sites** group: list of blacklisted hostnames, each with a remove button. Add-hostname input at bottom.
|
||||||
|
|
||||||
|
All options use the standardised `setting-row` pattern: left (title + description), right (control).
|
||||||
|
|
||||||
|
### B3. Display section (Device)
|
||||||
|
|
||||||
|
Moves the existing password-coloring UI (digit color picker, symbol color picker, live swatch, reset) from its current location into a proper Display section card.
|
||||||
|
|
||||||
|
### B4. Security section (Vault)
|
||||||
|
|
||||||
|
**Recovery QR card** (three states, see Stream C for implementation):
|
||||||
|
|
||||||
|
- **State 1 — no QR:** amber warning ("▲ No recovery QR generated — losing your reference image would make this vault unrecoverable"), single "Generate recovery QR…" button.
|
||||||
|
- **State 2 — QR exists, at rest:** green status ("◉ Recovery QR is set up"), last-generated date. Buttons: "Show / print QR…" and "Regenerate…". **No QR is visible in this state.**
|
||||||
|
- **State 3 — explicit view:** modal overlay (scrim over main pane only). QR rendered at ~140×140px. Warning: "▲ Close this window before stepping away. This QR is only displayed, never saved." Actions: "⎙ Print" (triggers `window.print()` scoped to modal) and "Done" (dismisses).
|
||||||
|
|
||||||
|
**Trusted devices** group: subsumes the current `⌬ devices` sidebar nav entry. Each registered device shows name, registration date, fingerprint, and a revoke button. "Register this device" entry for unregistered browsers. Once Stream B lands, the `⌬ devices` button is removed from the vault sidebar nav (settings → Security replaces it).
|
||||||
|
|
||||||
|
### B5. Generator section (Vault)
|
||||||
|
|
||||||
|
Pulls the existing generator-defaults content from `settings-vault.ts` into the new section layout. No functional changes — just consistent styling.
|
||||||
|
|
||||||
|
### B6. Retention section (Vault)
|
||||||
|
|
||||||
|
Pulls the existing retention content (trash retention, field history retention). No functional changes.
|
||||||
|
|
||||||
|
### B7. Backup section (Vault)
|
||||||
|
|
||||||
|
Pulls the existing backup & restore section. No functional changes.
|
||||||
|
|
||||||
|
### B8. Import section (Vault)
|
||||||
|
|
||||||
|
Pulls the existing import section. No functional changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stream C — Recovery QR
|
||||||
|
|
||||||
|
### C1. Rust core — `relicario-core/src/recovery_qr.rs`
|
||||||
|
|
||||||
|
Per the existing spec at `docs/superpowers/specs/2026-05-01-recovery-qr-design.md`. Key implementation points:
|
||||||
|
|
||||||
|
**KDF input:**
|
||||||
|
```
|
||||||
|
b"relicario-recovery-v1\0" || u64_be(len(nfc(passphrase))) || nfc(passphrase)
|
||||||
|
```
|
||||||
|
Fed to Argon2id with production params (`m=64MiB, t=3, p=4`), fresh 32-byte salt per generation.
|
||||||
|
|
||||||
|
**Wrap:** `XChaCha20-Poly1305(wrap_key, nonce=OsRng(24), image_secret)` — 32+16=48 bytes ciphertext.
|
||||||
|
|
||||||
|
**Binary payload (109 bytes):**
|
||||||
|
```
|
||||||
|
[magic "RREC" 4B][version 0x01 1B][salt 32B][nonce 24B][ciphertext 48B]
|
||||||
|
```
|
||||||
|
|
||||||
|
**QR encoding:** byte mode, error-correction M, version 6 (41×41 modules). Library: `qrcode` crate (already in workspace or add it).
|
||||||
|
|
||||||
|
**API surface:**
|
||||||
|
```rust
|
||||||
|
pub struct RecoveryQrPayload { /* opaque */ }
|
||||||
|
|
||||||
|
pub fn generate_recovery_qr(
|
||||||
|
passphrase: &str,
|
||||||
|
image_secret: &[u8; 32],
|
||||||
|
) -> Result<RecoveryQrPayload, RelicarioError>;
|
||||||
|
|
||||||
|
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String;
|
||||||
|
|
||||||
|
pub fn unwrap_recovery_qr(
|
||||||
|
payload_bytes: &[u8],
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>, RelicarioError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The payload bytes are never written to disk by this module — callers are responsible for rendering only.
|
||||||
|
|
||||||
|
**Passphrase entropy floor:** enforce `zxcvbn score ≥ 3` at vault init in the CLI and the setup wizard (already gated in the extension by 1C-α; confirm CLI `create` command applies the same gate).
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `crates/relicario-core/src/recovery_qr.rs` — new module
|
||||||
|
- `crates/relicario-core/src/lib.rs` — pub mod recovery_qr
|
||||||
|
- `crates/relicario-core/src/error.rs` — add `RecoveryQr` error variants if needed
|
||||||
|
- `crates/relicario-core/Cargo.toml` — add `qrcode` crate
|
||||||
|
- `crates/relicario-core/tests/` — new `recovery_qr.rs` test file
|
||||||
|
|
||||||
|
### C2. CLI — `relicario recovery-qr` subcommand group
|
||||||
|
|
||||||
|
```
|
||||||
|
relicario recovery-qr generate # prompts passphrase, renders QR to terminal (kitty/iTerm2 inline protocol or ASCII fallback)
|
||||||
|
relicario recovery-qr unwrap # prompts passphrase, prints image_secret as hex
|
||||||
|
```
|
||||||
|
|
||||||
|
`generate` never writes a file. It renders the QR inline in the terminal using the Kitty graphics protocol if `$TERM` indicates support, falling back to ASCII art via the `qrcode` crate's built-in ASCII renderer.
|
||||||
|
|
||||||
|
**Files affected:** `crates/relicario-cli/src/main.rs`
|
||||||
|
|
||||||
|
### C3. WASM bindings
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// relicario-wasm/src/lib.rs
|
||||||
|
generate_recovery_qr(passphrase: &str, image_secret: &[u8]) -> Result<String, JsValue> // returns SVG string
|
||||||
|
unwrap_recovery_qr(payload_b64: &str, passphrase: &str) -> Result<Vec<u8>, JsValue> // returns image_secret bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:** `crates/relicario-wasm/src/lib.rs`, `crates/relicario-wasm/Cargo.toml`
|
||||||
|
|
||||||
|
### C4. Extension — Recovery QR in Security settings
|
||||||
|
|
||||||
|
Implement the three-state Security section card described in B4:
|
||||||
|
|
||||||
|
- State determined by `chrome.storage.local.recovery_qr_generated_at` (timestamp or null).
|
||||||
|
- "Generate recovery QR…" button: calls WASM `generate_recovery_qr(passphrase, image_secret)` → stores `recovery_qr_generated_at = Date.now()` in local storage → transitions to State 3 (show modal with SVG).
|
||||||
|
- "Show / print QR…" button: re-derives QR (requires vault to be unlocked, master key in session) → shows State 3 modal.
|
||||||
|
- "Regenerate…" button: same as generate, with a confirmation step first.
|
||||||
|
- Print: injects SVG into a `<iframe>` styled for print, calls `iframe.contentWindow.print()`.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- New `extension/src/popup/components/settings-security.ts`
|
||||||
|
- `extension/src/popup/components/settings.ts` — wire Security section
|
||||||
|
|
||||||
|
### C5. Extension — Recovery QR in setup wizard (Step 5 "Done")
|
||||||
|
|
||||||
|
The wizard's final step adds a **skippable banner** above the "Download reference image" button:
|
||||||
|
|
||||||
|
```
|
||||||
|
◫ Generate a recovery QR before you go
|
||||||
|
If you lose your reference image, this QR lets you recover your vault.
|
||||||
|
[Generate now] [Skip — I'll do this in Settings]
|
||||||
|
```
|
||||||
|
|
||||||
|
- "Generate now": calls WASM → shows QR modal inline on the wizard page. After dismissing, banner becomes green "◉ Recovery QR generated".
|
||||||
|
- "Skip": dismisses banner permanently for this session; user can generate later from Settings → Security.
|
||||||
|
- The banner is informational, not a blocker. Vault is fully usable without a recovery QR.
|
||||||
|
|
||||||
|
**Files affected:** `extension/src/setup/setup.ts`
|
||||||
|
|
||||||
|
### C6. Setup wizard redesign (Style C)
|
||||||
|
|
||||||
|
Redesign the setup wizard from the current single-column glass-card layout to **Style C (centered hero card)**:
|
||||||
|
|
||||||
|
- Full-page dark background (`--bg-page`).
|
||||||
|
- Relicario logo glyph + wordmark centered at top.
|
||||||
|
- **Colored progress track**: 5 segments, `--success` fill for completed, `--gold` for current, `--border` for pending.
|
||||||
|
- Centered card (max-width 560px): step eyebrow label ("Step N of 5 · <step name>"), h2 heading, hint text, form content, action row.
|
||||||
|
- **Glyphs not emoji** throughout. Mode cards use `◈` (create new) and `⌥` (attach). Mode-card glyphs at 28px. All other icons from the existing glyph set.
|
||||||
|
- Probe-banner success state uses `◉` (filled circle, matches ⊙/⊘ family).
|
||||||
|
- Action row: "◂ back" text button (left), "Continue ▸" primary button (right).
|
||||||
|
|
||||||
|
This is a pure CSS/markup change — no logic changes.
|
||||||
|
|
||||||
|
**Files affected:** `extension/src/setup/setup.ts`, setup CSS (inline or extracted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive behaviour
|
||||||
|
|
||||||
|
| Viewport | Fullscreen behaviour |
|
||||||
|
|---|---|
|
||||||
|
| ≥ 960px | 3-column: sidebar + list + drawer |
|
||||||
|
| 720–960px | 2-column: sidebar + list; drawer pushes full-pane on click |
|
||||||
|
| ≤ 720px | Sidebar collapses (hamburger/icon strip); list full-width; detail is full-page push |
|
||||||
|
|
||||||
|
The popup is always narrow (~340px) — popup-specific components are unaffected by the fullscreen responsive rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance criteria (shared)
|
||||||
|
|
||||||
|
- `cargo test` green. `bun run test` green. `bun run build` + `bun run build:firefox` clean.
|
||||||
|
- No raw `snake_case` error codes in any UI surface.
|
||||||
|
- No emoji in any UI surface — all icons are Unicode monochrome glyphs.
|
||||||
|
- `glyphs.ts` is the single source of truth for all icon constants; no inline Unicode literals at call sites.
|
||||||
|
- QR code is never written to any file, `chrome.storage`, or git. `recovery_qr_generated_at` (timestamp only) is the only persisted artifact.
|
||||||
|
- Settings left-nav sections all render without console errors. Device sections read/write `chrome.storage.local`. Vault sections read/write `VaultSettings`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stream split summary (for multi-agent kickoff)
|
||||||
|
|
||||||
|
| Stream | Owner | Core files | Dependency |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A — Fullscreen + popup layout | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `glyphs.ts` | none |
|
||||||
|
| B — Settings UX | DEV-B | `settings.ts`, `settings-vault.ts`, new `settings-security.ts` | waits for C4 interface (can stub) |
|
||||||
|
| C — Recovery QR | DEV-C | `recovery_qr.rs`, `relicario-wasm/src/lib.rs`, `setup.ts`, `settings-security.ts` | none |
|
||||||
|
|
||||||
|
B and C share `settings-security.ts` — DEV-C owns the file, DEV-B wires it into the nav. Coordinate on interface (component export signature) before DEV-B proceeds with B4.
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="redThecaSm" cx="0.4" cy="0.35">
|
<radialGradient id="redThecaSm" cx="0.4" cy="0.35">
|
||||||
<stop offset="0%" stop-color="#9a1a1a"/>
|
<stop offset="0%" stop-color="#7d2622"/>
|
||||||
<stop offset="100%" stop-color="#3a0a0a"/>
|
<stop offset="100%" stop-color="#2c0d0a"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<linearGradient id="goldRingSm" x1="0" x2="1">
|
<linearGradient id="goldRingSm" x1="0" x2="1">
|
||||||
<stop offset="0%" stop-color="#d2ab43"/>
|
<stop offset="0%" stop-color="#a88a4a"/>
|
||||||
<stop offset="50%" stop-color="#f5d97a"/>
|
<stop offset="50%" stop-color="#cdb47a"/>
|
||||||
<stop offset="100%" stop-color="#7c5719"/>
|
<stop offset="100%" stop-color="#5a3f12"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -15,13 +15,13 @@
|
|||||||
<circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/>
|
<circle cx="8" cy="9" r="6.5" fill="url(#goldRingSm)"/>
|
||||||
<circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/>
|
<circle cx="8" cy="9" r="4.8" fill="url(#redThecaSm)"/>
|
||||||
|
|
||||||
<!-- Asterisk-as-3-bars -->
|
<!-- Asterisk-as-3-bars (translucent) -->
|
||||||
<g transform="translate(8, 9)" stroke="#f5d97a" stroke-width="1.2" stroke-linecap="round">
|
<g transform="translate(8, 9)" stroke="#dac8a0" stroke-width="1.2" stroke-linecap="round" stroke-opacity="0.8">
|
||||||
<line x1="0" y1="-3" x2="0" y2="3"/>
|
<line x1="0" y1="-3" x2="0" y2="3"/>
|
||||||
<line x1="-2.6" y1="-1.5" x2="2.6" y2="1.5"/>
|
<line x1="-2.6" y1="-1.5" x2="2.6" y2="1.5"/>
|
||||||
<line x1="-2.6" y1="1.5" x2="2.6" y2="-1.5"/>
|
<line x1="-2.6" y1="1.5" x2="2.6" y2="-1.5"/>
|
||||||
</g>
|
</g>
|
||||||
<circle cx="8" cy="9" r="0.7" fill="#fff3cf"/>
|
<circle cx="8" cy="9" r="0.7" fill="#dac8a0"/>
|
||||||
|
|
||||||
<!-- Fleur (3 tips) -->
|
<!-- Fleur (3 tips) -->
|
||||||
<path d="M 8 0 L 7.2 2.5 L 8.8 2.5 Z" fill="url(#goldRingSm)"/>
|
<path d="M 8 0 L 7.2 2.5 L 8.8 2.5 Z" fill="url(#goldRingSm)"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -1,79 +1,93 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 240" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="redTheca" cx="0.4" cy="0.35">
|
<radialGradient id="redTheca" cx="0.4" cy="0.35">
|
||||||
<stop offset="0%" stop-color="#9a1a1a"/>
|
<stop offset="0%" stop-color="#7d2622"/>
|
||||||
<stop offset="100%" stop-color="#3a0a0a"/>
|
<stop offset="100%" stop-color="#2c0d0a"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<linearGradient id="goldRing" x1="0" x2="1">
|
<linearGradient id="goldRing" x1="0" x2="1">
|
||||||
<stop offset="0%" stop-color="#d2ab43"/>
|
<stop offset="0%" stop-color="#a88a4a"/>
|
||||||
<stop offset="50%" stop-color="#f5d97a"/>
|
<stop offset="50%" stop-color="#cdb47a"/>
|
||||||
<stop offset="100%" stop-color="#7c5719"/>
|
<stop offset="100%" stop-color="#5a3f12"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="goldHi" x1="0" x2="1">
|
<linearGradient id="goldHi" x1="0" x2="1">
|
||||||
<stop offset="0%" stop-color="#fde9a8"/>
|
<stop offset="0%" stop-color="#dac8a0"/>
|
||||||
<stop offset="100%" stop-color="#d2ab43"/>
|
<stop offset="100%" stop-color="#a88a4a"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="gemFacetLight" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#dac8a0" stop-opacity="0.8"/>
|
||||||
|
<stop offset="100%" stop-color="#cdb47a" stop-opacity="0.3"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gemFacetDark" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#6e4d18" stop-opacity="0.7"/>
|
||||||
|
<stop offset="100%" stop-color="#5a3f12" stop-opacity="0.25"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="gemCore" cx="0.4" cy="0.35">
|
||||||
|
<stop offset="0%" stop-color="#dac8a0" stop-opacity="0.85"/>
|
||||||
|
<stop offset="60%" stop-color="#b89556" stop-opacity="0.4"/>
|
||||||
|
<stop offset="100%" stop-color="#7d2622" stop-opacity="0.2"/>
|
||||||
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- Pedestal (compact) -->
|
<!-- Pedestal -->
|
||||||
<ellipse cx="110" cy="226" rx="44" ry="5" fill="url(#goldRing)"/>
|
<ellipse cx="110" cy="226" rx="44" ry="5" fill="url(#goldRing)"/>
|
||||||
<rect x="78" y="212" width="64" height="14" rx="2" fill="url(#goldRing)"/>
|
<rect x="78" y="212" width="64" height="14" rx="2" fill="url(#goldRing)"/>
|
||||||
<rect x="98" y="202" width="24" height="12" fill="url(#goldRing)"/>
|
<rect x="98" y="202" width="24" height="12" fill="url(#goldRing)"/>
|
||||||
<ellipse cx="110" cy="208" rx="14" ry="3" fill="#7c5719"/>
|
<ellipse cx="110" cy="208" rx="14" ry="3" fill="#5a3f12"/>
|
||||||
<ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/>
|
<ellipse cx="110" cy="202" rx="18" ry="4" fill="url(#goldRing)"/>
|
||||||
|
|
||||||
<!-- Body, bezel, theca -->
|
<!-- Body, bezel, theca -->
|
||||||
<circle cx="110" cy="130" r="72" fill="url(#goldRing)"/>
|
<circle cx="110" cy="130" r="72" fill="url(#goldRing)"/>
|
||||||
<path d="M 110 58 A 72 72 0 0 0 38 130" stroke="#fde9a8" stroke-width="2" fill="none" opacity="0.6"/>
|
<path d="M 110 58 A 72 72 0 0 0 38 130" stroke="#dac8a0" stroke-width="2" fill="none" opacity="0.5"/>
|
||||||
<circle cx="110" cy="130" r="60" fill="#7c5719"/>
|
<circle cx="110" cy="130" r="60" fill="#5a3f12"/>
|
||||||
<circle cx="110" cy="130" r="56" fill="url(#redTheca)"/>
|
<circle cx="110" cy="130" r="56" fill="url(#redTheca)"/>
|
||||||
<ellipse cx="86" cy="108" rx="16" ry="7" fill="#ffffff" opacity="0.14" transform="rotate(-30 86 108)"/>
|
<ellipse cx="86" cy="108" rx="16" ry="7" fill="#ffffff" opacity="0.10" transform="rotate(-30 86 108)"/>
|
||||||
|
|
||||||
<!-- Asterisk gem with pinwheel facets -->
|
<!-- Asterisk gem with translucent gradient facets -->
|
||||||
<g transform="translate(110, 130)">
|
<g transform="translate(110, 130)">
|
||||||
<g transform="rotate(0)">
|
<g transform="rotate(0)">
|
||||||
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="url(#gemFacetLight)"/>
|
||||||
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
|
||||||
</g>
|
</g>
|
||||||
<g transform="rotate(60)">
|
<g transform="rotate(60)">
|
||||||
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="url(#gemFacetLight)"/>
|
||||||
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
|
||||||
</g>
|
</g>
|
||||||
<g transform="rotate(120)">
|
<g transform="rotate(120)">
|
||||||
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="url(#gemFacetLight)"/>
|
||||||
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
|
||||||
</g>
|
</g>
|
||||||
<g transform="rotate(180)">
|
<g transform="rotate(180)">
|
||||||
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="url(#gemFacetLight)"/>
|
||||||
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
|
||||||
</g>
|
</g>
|
||||||
<g transform="rotate(240)">
|
<g transform="rotate(240)">
|
||||||
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="url(#gemFacetLight)"/>
|
||||||
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
|
||||||
</g>
|
</g>
|
||||||
<g transform="rotate(300)">
|
<g transform="rotate(300)">
|
||||||
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="#f5d97a"/>
|
<path d="M 0 0 L -4.5 -3.5 C -5.5 -16, -3.5 -29, 0 -36 Z" fill="url(#gemFacetLight)"/>
|
||||||
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="#8a5e1c"/>
|
<path d="M 0 0 L 4.5 -3.5 C 5.5 -16, 3.5 -29, 0 -36 Z" fill="url(#gemFacetDark)"/>
|
||||||
</g>
|
</g>
|
||||||
<polygon points="0,-6 5.2,-3 5.2,3 0,6 -5.2,3 -5.2,-3" fill="#d2ab43" stroke="#7c5719" stroke-width="0.6"/>
|
<polygon points="0,-6 5.2,-3 5.2,3 0,6 -5.2,3 -5.2,-3" fill="url(#gemCore)" stroke="#5a3f12" stroke-width="0.5" stroke-opacity="0.6"/>
|
||||||
<circle cx="-1.5" cy="-2" r="1.4" fill="#fff3cf"/>
|
<circle cx="-1.8" cy="-2.2" r="1.6" fill="#dac8a0" opacity="0.95"/>
|
||||||
|
<circle cx="1.5" cy="2" r="0.6" fill="#dac8a0" opacity="0.5"/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Hinge collar -->
|
<!-- Hinge collar -->
|
||||||
<rect x="98" y="50" width="24" height="10" rx="2" fill="url(#goldRing)"/>
|
<rect x="98" y="50" width="24" height="10" rx="2" fill="url(#goldRing)"/>
|
||||||
<line x1="100" y1="55" x2="120" y2="55" stroke="#7c5719" stroke-width="0.8"/>
|
<line x1="100" y1="55" x2="120" y2="55" stroke="#5a3f12" stroke-width="0.8"/>
|
||||||
|
|
||||||
<!-- Fleur-de-lis -->
|
<!-- Fleur-de-lis -->
|
||||||
<g transform="translate(110, 50)">
|
<g transform="translate(110, 50)">
|
||||||
<rect x="-3.5" y="-12" width="7" height="12" fill="url(#goldRing)"/>
|
<rect x="-3.5" y="-12" width="7" height="12" fill="url(#goldRing)"/>
|
||||||
<rect x="-16" y="-18" width="32" height="7" rx="1.5" fill="url(#goldRing)"/>
|
<rect x="-16" y="-18" width="32" height="7" rx="1.5" fill="url(#goldRing)"/>
|
||||||
<rect x="-3" y="-19" width="6" height="9" rx="0.8" fill="#7c5719"/>
|
<rect x="-3" y="-19" width="6" height="9" rx="0.8" fill="#5a3f12"/>
|
||||||
<path d="M 0 -18 Q -8 -36, -4 -54 Q -1 -62, 0 -64 Q 1 -62, 4 -54 Q 8 -36, 0 -18 Z" fill="url(#goldRing)"/>
|
<path d="M 0 -18 Q -8 -36, -4 -54 Q -1 -62, 0 -64 Q 1 -62, 4 -54 Q 8 -36, 0 -18 Z" fill="url(#goldRing)"/>
|
||||||
<path d="M 0 -22 Q -2.5 -36, 0 -52 Q 2.5 -36, 0 -22 Z" fill="#7c5719" opacity="0.55"/>
|
<path d="M 0 -22 Q -2.5 -36, 0 -52 Q 2.5 -36, 0 -22 Z" fill="#5a3f12" opacity="0.55"/>
|
||||||
<circle cx="0" cy="-66" r="2.5" fill="url(#goldHi)"/>
|
<circle cx="0" cy="-66" r="2.5" fill="url(#goldHi)"/>
|
||||||
<path d="M -4 -18 Q -22 -22, -26 -38 Q -22 -50, -16 -50 Q -16 -38, -10 -32 Q -6 -28, -4 -28 Z" fill="url(#goldRing)"/>
|
<path d="M -4 -18 Q -22 -22, -26 -38 Q -22 -50, -16 -50 Q -16 -38, -10 -32 Q -6 -28, -4 -28 Z" fill="url(#goldRing)"/>
|
||||||
<ellipse cx="-25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(-20 -25 -44)"/>
|
<ellipse cx="-25" cy="-44" rx="2" ry="3" fill="#5a3f12" opacity="0.4" transform="rotate(-20 -25 -44)"/>
|
||||||
<path d="M 4 -18 Q 22 -22, 26 -38 Q 22 -50, 16 -50 Q 16 -38, 10 -32 Q 6 -28, 4 -28 Z" fill="url(#goldRing)"/>
|
<path d="M 4 -18 Q 22 -22, 26 -38 Q 22 -50, 16 -50 Q 16 -38, 10 -32 Q 6 -28, 4 -28 Z" fill="url(#goldRing)"/>
|
||||||
<ellipse cx="25" cy="-44" rx="2" ry="3" fill="#7c5719" opacity="0.4" transform="rotate(20 25 -44)"/>
|
<ellipse cx="25" cy="-44" rx="2" ry="3" fill="#5a3f12" opacity="0.4" transform="rotate(20 25 -44)"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "relicario",
|
"name": "Relicario",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Two-factor encrypted password manager",
|
"description": "Two-factor encrypted password manager",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
|
|||||||
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
13
extension/src/__stubs__/relicario_wasm.stub.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Stub for the runtime-only WASM module. Used by vitest so that modules
|
||||||
|
// importing relicario_wasm.js can be loaded in a Node/happy-dom environment.
|
||||||
|
// Individual tests that exercise WASM calls should mock the relevant exports.
|
||||||
|
|
||||||
|
export default async function init(): Promise<void> {}
|
||||||
|
export const unlock = (): never => { throw new Error('wasm stub: unlock not mocked'); };
|
||||||
|
export const lock = (): void => {};
|
||||||
|
export const manifest_encrypt = (): never => { throw new Error('wasm stub: manifest_encrypt not mocked'); };
|
||||||
|
export const manifest_decrypt = (): never => { throw new Error('wasm stub: manifest_decrypt not mocked'); };
|
||||||
|
export const settings_encrypt = (): never => { throw new Error('wasm stub: settings_encrypt not mocked'); };
|
||||||
|
export const default_vault_settings_json = (): string => '{}';
|
||||||
|
export const embed_image_secret = (): never => { throw new Error('wasm stub: embed_image_secret not mocked'); };
|
||||||
|
export const register_device = (): never => { throw new Error('wasm stub: register_device not mocked'); };
|
||||||
@@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { sendMessage } from '../../../shared/state';
|
import { sendMessage } from '../../../shared/state';
|
||||||
|
import { DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR } from '../../../shared/color-scheme';
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||||
|
const store: Record<string, unknown> = { ...initial };
|
||||||
|
(global as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
sync: {
|
||||||
|
get: vi.fn((key: string) => Promise.resolve(
|
||||||
|
key in store ? { [key]: store[key] } : {})),
|
||||||
|
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||||
|
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
function settingsResponses() {
|
function settingsResponses() {
|
||||||
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
// Two parallel calls in renderSettings: get_settings + get_blacklist.
|
||||||
@@ -30,6 +46,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders a Sync now button', async () => {
|
it('renders a Sync now button', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
|
|
||||||
await renderSettings(app);
|
await renderSettings(app);
|
||||||
@@ -38,6 +55,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
it('clicking Sync now sends a sync message and shows feedback on success', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
@@ -52,6 +70,7 @@ describe('settings view', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the error when sync fails', async () => {
|
it('shows the error when sync fails', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
settingsResponses();
|
settingsResponses();
|
||||||
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
|
||||||
|
|
||||||
@@ -64,3 +83,109 @@ describe('settings view', () => {
|
|||||||
expect(status.textContent).toMatch(/remote_unreachable/);
|
expect(status.textContent).toMatch(/remote_unreachable/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('settings Display section', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||||
|
expect(digitInput).not.toBeNull();
|
||||||
|
expect(symbolInput).not.toBeNull();
|
||||||
|
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(symbolInput!.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pickers with stored values when storage has a scheme', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||||
|
});
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
|
||||||
|
expect(digitInput!.value).toBe('#112233');
|
||||||
|
expect(symbolInput!.value).toBe('#aabbcc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a color-preview-swatch element', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
expect(app.querySelector('#display-swatch')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing digit color calls saveColorScheme with updated scheme', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||||
|
digitInput.value = '#ff0000';
|
||||||
|
digitInput.dispatchEvent(new Event('change'));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
password_display_scheme: expect.objectContaining({ digit_color: '#ff0000' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||||
|
symbolInput.value = '#00ff00';
|
||||||
|
symbolInput.dispatchEvent(new Event('change'));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
password_display_scheme: expect.objectContaining({ symbol_color: '#00ff00' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking reset calls chrome.storage.sync.remove and restores defaults', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
|
||||||
|
});
|
||||||
|
settingsResponses();
|
||||||
|
|
||||||
|
await renderSettings(app);
|
||||||
|
|
||||||
|
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
|
||||||
|
resetBtn.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
|
||||||
|
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
|
||||||
|
|
||||||
|
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
|
||||||
|
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
|
||||||
|
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
45
extension/src/popup/components/__tests__/unlock.test.ts
Normal file
45
extension/src/popup/components/__tests__/unlock.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderUnlock } from '../unlock';
|
||||||
|
|
||||||
|
vi.mock('../../../shared/state', () => ({
|
||||||
|
getState: () => ({ loading: false, error: null }),
|
||||||
|
setState: vi.fn(),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
openVaultTab: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('renderUnlock', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the logo lockup (logo + brand + tagline)', () => {
|
||||||
|
renderUnlock(app);
|
||||||
|
expect(app.querySelector('.brand-logo')).toBeTruthy();
|
||||||
|
expect(app.querySelector('.brand')?.textContent).toBe('Relicario');
|
||||||
|
expect(app.querySelector('.tagline')?.textContent).toContain('two-factor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the unlock form inside a .glass card', () => {
|
||||||
|
renderUnlock(app);
|
||||||
|
const glass = app.querySelector('.glass');
|
||||||
|
expect(glass).toBeTruthy();
|
||||||
|
expect(glass!.querySelector('#passphrase-input')).toBeTruthy();
|
||||||
|
expect(glass!.querySelector('.btn-primary')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders open-vault and settings as secondary buttons outside the card', () => {
|
||||||
|
renderUnlock(app);
|
||||||
|
const vaultBtn = app.querySelector('#vault-btn');
|
||||||
|
const settingsBtn = app.querySelector('#settings-btn');
|
||||||
|
expect(vaultBtn?.classList.contains('btn-secondary')).toBe(true);
|
||||||
|
expect(settingsBtn?.classList.contains('btn-secondary')).toBe(true);
|
||||||
|
// They should NOT be inside the .glass card
|
||||||
|
const glass = app.querySelector('.glass');
|
||||||
|
expect(glass!.contains(vaultBtn!)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
import type { Device } from '../../shared/types';
|
import type { Device } from '../../shared/types';
|
||||||
|
|
||||||
|
interface RevokedEntry {
|
||||||
|
name: string;
|
||||||
|
public_key: string;
|
||||||
|
revoked_at: number;
|
||||||
|
revoked_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
function relativeTime(unixSec: number): string {
|
function relativeTime(unixSec: number): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const diff = now - unixSec;
|
const diff = now - unixSec;
|
||||||
@@ -36,16 +43,62 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
|||||||
const stored = await chrome.storage.local.get(['device_name']);
|
const stored = await chrome.storage.local.get(['device_name']);
|
||||||
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
const currentDeviceName: string | undefined = stored.device_name as string | undefined;
|
||||||
|
|
||||||
// Fetch device list
|
// Fetch active device list and revoked list in parallel
|
||||||
const resp = await sendMessage({ type: 'list_devices' });
|
const [devicesResp, revokedResp] = await Promise.all([
|
||||||
if (!resp.ok) {
|
sendMessage({ type: 'list_devices' }),
|
||||||
|
sendMessage({ type: 'list_revoked' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!devicesResp.ok) {
|
||||||
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
app.innerHTML = `<div class="pad"><p class="error">Failed to load devices</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = (resp.data as { devices: Device[] }).devices;
|
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
||||||
|
const revokedDevices: RevokedEntry[] = revokedResp.ok
|
||||||
|
? (revokedResp.data as { revoked: RevokedEntry[] }).revoked
|
||||||
|
: [];
|
||||||
|
|
||||||
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
const isRegistered = currentDeviceName && devices.some((d) => d.name === currentDeviceName);
|
||||||
|
|
||||||
|
const activeDevicesHtml = devices.length === 0
|
||||||
|
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
||||||
|
: devices.map((d) => {
|
||||||
|
const isCurrentDevice = d.name === currentDeviceName;
|
||||||
|
return `
|
||||||
|
<div class="device-row">
|
||||||
|
<div class="device-row__info">
|
||||||
|
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
|
||||||
|
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
|
||||||
|
</div>
|
||||||
|
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const revokedSectionHtml = revokedDevices.length === 0 ? '' : `
|
||||||
|
<details class="revoked-section" style="margin-top:16px;">
|
||||||
|
<summary class="muted" style="cursor:pointer;font-size:0.85em;">
|
||||||
|
${revokedDevices.length} revoked device${revokedDevices.length !== 1 ? 's' : ''}
|
||||||
|
</summary>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
${revokedDevices.map((r) => `
|
||||||
|
<div class="device-row device-row--revoked">
|
||||||
|
<div class="device-row__info">
|
||||||
|
<span class="device-row__name" style="text-decoration:line-through;opacity:0.5;">
|
||||||
|
${escapeHtml(r.name)}
|
||||||
|
</span>
|
||||||
|
<span class="device-row__meta">
|
||||||
|
revoked ${relativeTime(r.revoked_at)}
|
||||||
|
${r.revoked_by !== 'unknown' ? ` by ${escapeHtml(r.revoked_by)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="devices-header">
|
<div class="devices-header">
|
||||||
@@ -58,20 +111,8 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
|
|||||||
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
<button class="btn btn-primary" id="register-btn">Register this device</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${devices.length === 0
|
${activeDevicesHtml}
|
||||||
? `<p class="muted" style="text-align:center;margin-top:32px;">No devices registered</p>`
|
${revokedSectionHtml}
|
||||||
: devices.map((d) => {
|
|
||||||
const isCurrentDevice = d.name === currentDeviceName;
|
|
||||||
return `
|
|
||||||
<div class="device-row">
|
|
||||||
<div class="device-row__info">
|
|
||||||
<span class="device-row__name">${escapeHtml(d.name)}${isCurrentDevice ? ' <span class="device-row__you">← you</span>' : ''}</span>
|
|
||||||
<span class="device-row__meta">added ${relativeTime(d.added_at)}</span>
|
|
||||||
</div>
|
|
||||||
${isCurrentDevice ? '' : `<button class="device-row__revoke" data-revoke="${escapeHtml(d.name)}">revoke</button>`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/// Field history view — shows password/concealed field history for an item.
|
/// Field history view — shows password/concealed field history for an item.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
import type { FieldHistoryView } from '../../shared/types';
|
import type { FieldHistoryView } from '../../shared/types';
|
||||||
|
|
||||||
function relativeTime(unixSec: number): string {
|
function relativeTime(unixSec: number): string {
|
||||||
@@ -103,6 +104,16 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Colorize revealed entries: replace plain-text content with colorized spans
|
||||||
|
app.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el) => {
|
||||||
|
const key = el.closest<HTMLElement>('.history-entry')?.dataset.entry ?? '';
|
||||||
|
const plaintext = valueStore.get(key);
|
||||||
|
if (plaintext !== undefined) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(colorizePassword(plaintext));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Wire handlers
|
// Wire handlers
|
||||||
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
/// copy click handlers on any rendered rows.
|
/// copy click handlers on any rendered rows.
|
||||||
|
|
||||||
import { escapeHtml } from '../../shared/state';
|
import { escapeHtml } from '../../shared/state';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
||||||
|
|
||||||
export interface RowOpts {
|
export interface RowOpts {
|
||||||
@@ -46,6 +47,7 @@ export interface ConcealedRowOpts {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
kind?: 'password' | 'concealed';
|
||||||
monospace?: boolean;
|
monospace?: boolean;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
}
|
}
|
||||||
@@ -53,12 +55,15 @@ export interface ConcealedRowOpts {
|
|||||||
/// Concealed row — value rendered hidden until the user clicks "show".
|
/// Concealed row — value rendered hidden until the user clicks "show".
|
||||||
/// Plaintext is stored in `data-field-value` on the row element and copied
|
/// Plaintext is stored in `data-field-value` on the row element and copied
|
||||||
/// to the visible value span on reveal. Copy button always copies plaintext.
|
/// to the visible value span on reveal. Copy button always copies plaintext.
|
||||||
|
/// When `kind` is "password", wireFieldHandlers applies colorizePassword on
|
||||||
|
/// reveal so digits/symbols/letters are rendered in distinct colours.
|
||||||
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
||||||
const { id, label, value, monospace, multiline } = opts;
|
const { id, label, value, kind, monospace, multiline } = opts;
|
||||||
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
||||||
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||||||
|
const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : '';
|
||||||
return `
|
return `
|
||||||
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
|
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"${kindAttr}>
|
||||||
<span class="field-row__label">${escapeHtml(label)}</span>
|
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||||
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
||||||
<span class="field-row__actions">
|
<span class="field-row__actions">
|
||||||
@@ -101,7 +106,13 @@ export function wireFieldHandlers(scope: HTMLElement): void {
|
|||||||
row.setAttribute('data-revealed', 'false');
|
row.setAttribute('data-revealed', 'false');
|
||||||
btn.textContent = 'show';
|
btn.textContent = 'show';
|
||||||
} else {
|
} else {
|
||||||
valueEl.textContent = plaintext;
|
const isPassword = row.getAttribute('data-field-kind') === 'password';
|
||||||
|
valueEl.textContent = '';
|
||||||
|
if (isPassword) {
|
||||||
|
valueEl.appendChild(colorizePassword(plaintext));
|
||||||
|
} else {
|
||||||
|
valueEl.textContent = plaintext;
|
||||||
|
}
|
||||||
row.setAttribute('data-revealed', 'true');
|
row.setAttribute('data-revealed', 'true');
|
||||||
btn.textContent = 'hide';
|
btn.textContent = 'hide';
|
||||||
}
|
}
|
||||||
@@ -150,6 +161,7 @@ export function renderSections(item: Item, idPrefix: string): string {
|
|||||||
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
value: field.value.value,
|
value: field.value.value,
|
||||||
|
kind: field.value.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { sendMessage } from '../../shared/state';
|
import { sendMessage } from '../../shared/state';
|
||||||
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
interface UiKnobs {
|
interface UiKnobs {
|
||||||
kind: 'random' | 'bip39';
|
kind: 'random' | 'bip39';
|
||||||
@@ -138,7 +139,10 @@ export function openGeneratorPanel(opts: OpenPanelOpts): void {
|
|||||||
const d = resp.data as { password?: string; passphrase?: string };
|
const d = resp.data as { password?: string; passphrase?: string };
|
||||||
currentPreview = d.password ?? d.passphrase ?? '';
|
currentPreview = d.password ?? d.passphrase ?? '';
|
||||||
const el = host.querySelector('.preview__value');
|
const el = host.querySelector('.preview__value');
|
||||||
if (el) el.textContent = currentPreview;
|
if (el) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(colorizePassword(currentPreview));
|
||||||
|
}
|
||||||
updateValidation();
|
updateValidation();
|
||||||
}
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
|||||||
const type: ItemType = existing?.type ?? state.newType ?? 'login';
|
const type: ItemType = existing?.type ?? state.newType ?? 'login';
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'login': return login.renderForm(app, mode, existing);
|
case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup', externalActions: isInTab() });
|
||||||
case 'secure_note': return secureNote.renderForm(app, mode, existing);
|
case 'secure_note': return secureNote.renderForm(app, mode, existing);
|
||||||
case 'identity': return identity.renderForm(app, mode, existing);
|
case 'identity': return identity.renderForm(app, mode, existing);
|
||||||
case 'card': return card.renderForm(app, mode, existing);
|
case 'card': return card.renderForm(app, mode, existing);
|
||||||
|
|||||||
329
extension/src/popup/components/settings-security.ts
Normal file
329
extension/src/popup/components/settings-security.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
|
||||||
|
///
|
||||||
|
/// Exported contract:
|
||||||
|
/// renderSecuritySection(container, sessionHandle): renders into `container`
|
||||||
|
/// teardownSecuritySection(): removes any open QR modal
|
||||||
|
|
||||||
|
import { sendMessage, escapeHtml } from '../../shared/state';
|
||||||
|
import type { Device } from '../../shared/types';
|
||||||
|
|
||||||
|
// --- Relative time helper ---
|
||||||
|
|
||||||
|
function relativeTime(unixSec: number): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = now - unixSec;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
return `${Math.floor(diff / 2592000)}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal helpers ---
|
||||||
|
|
||||||
|
const MODAL_ID = 'relicario-qr-modal';
|
||||||
|
|
||||||
|
function removeModal(): void {
|
||||||
|
document.getElementById(MODAL_ID)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQrModal(svgContent: string): void {
|
||||||
|
removeModal();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = MODAL_ID;
|
||||||
|
overlay.style.cssText = [
|
||||||
|
'position:fixed', 'inset:0', 'z-index:9999',
|
||||||
|
'background:rgba(0,0,0,0.85)',
|
||||||
|
'display:flex', 'flex-direction:column',
|
||||||
|
'align-items:center', 'justify-content:center',
|
||||||
|
'padding:16px', 'box-sizing:border-box',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div style="
|
||||||
|
background:#161b22; border:1px solid #30363d; border-radius:8px;
|
||||||
|
padding:16px; max-width:340px; width:100%; text-align:center;
|
||||||
|
">
|
||||||
|
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
|
||||||
|
Recovery QR
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
|
||||||
|
Print or store this QR. It encodes your reference image secret,
|
||||||
|
protected by your passphrase.
|
||||||
|
</div>
|
||||||
|
<div id="relicario-qr-svg" style="
|
||||||
|
background:#fff; border-radius:4px; padding:8px;
|
||||||
|
display:inline-block; max-width:280px; width:100%;
|
||||||
|
">
|
||||||
|
${svgContent}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
|
||||||
|
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
|
||||||
|
|
||||||
|
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
|
||||||
|
const win = window.open('', '_blank', 'width=400,height=500');
|
||||||
|
if (!win) return;
|
||||||
|
win.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head><title>Recovery QR</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; display: flex; flex-direction: column; align-items: center;
|
||||||
|
font-family: sans-serif; padding: 24px; }
|
||||||
|
h2 { font-size: 16px; margin-bottom: 8px; }
|
||||||
|
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
|
||||||
|
svg { max-width: 280px; width: 100%; }
|
||||||
|
</style></head><body>
|
||||||
|
<h2>Relicario Recovery QR</h2>
|
||||||
|
<p>Scan with the Relicario app to recover your reference image secret.<br>
|
||||||
|
Keep this page in a safe physical location.</p>
|
||||||
|
${svgContent}
|
||||||
|
<script>window.onload = () => { window.print(); window.close(); }<\/script>
|
||||||
|
</body></html>
|
||||||
|
`);
|
||||||
|
win.document.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) removeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main render ---
|
||||||
|
|
||||||
|
export async function renderSecuritySection(
|
||||||
|
container: HTMLElement,
|
||||||
|
sessionHandle: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
// Read timestamp from device-local storage (never the QR payload itself)
|
||||||
|
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
|
||||||
|
const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
|
||||||
|
|
||||||
|
const isUnlocked = sessionHandle !== null;
|
||||||
|
|
||||||
|
// --- QR status section ---
|
||||||
|
let qrStatusHtml: string;
|
||||||
|
if (generatedAt === null) {
|
||||||
|
qrStatusHtml = `
|
||||||
|
<div style="
|
||||||
|
display:flex; align-items:flex-start; gap:10px;
|
||||||
|
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
|
||||||
|
padding:10px; margin-bottom:12px;
|
||||||
|
">
|
||||||
|
<span style="font-size:16px;">⚠</span>
|
||||||
|
<div style="flex:1; font-size:12px;">
|
||||||
|
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
|
||||||
|
No recovery QR generated
|
||||||
|
</div>
|
||||||
|
<div style="color:#8b949e;">
|
||||||
|
If you lose access to your reference image, you will be locked out permanently.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
id="sec-generate-qr"
|
||||||
|
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||||
|
style="width:100%; font-size:12px; margin-bottom:4px;"
|
||||||
|
>
|
||||||
|
Generate recovery QR…
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
qrStatusHtml = `
|
||||||
|
<div style="
|
||||||
|
display:flex; align-items:flex-start; gap:10px;
|
||||||
|
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
|
||||||
|
padding:10px; margin-bottom:12px;
|
||||||
|
">
|
||||||
|
<span style="font-size:16px;">✓</span>
|
||||||
|
<div style="flex:1; font-size:12px;">
|
||||||
|
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
|
||||||
|
Recovery QR set up
|
||||||
|
</div>
|
||||||
|
<div style="color:#8b949e;">
|
||||||
|
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:4px;">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
id="sec-show-qr"
|
||||||
|
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||||
|
style="flex:1; font-size:12px;"
|
||||||
|
>
|
||||||
|
Show / print QR…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
id="sec-regenerate-qr"
|
||||||
|
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
||||||
|
style="flex:1; font-size:12px;"
|
||||||
|
>
|
||||||
|
Regenerate…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Devices section ---
|
||||||
|
const devicesResp = await sendMessage({ type: 'list_devices' });
|
||||||
|
let devicesHtml: string;
|
||||||
|
if (!devicesResp.ok) {
|
||||||
|
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
|
||||||
|
} else {
|
||||||
|
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
||||||
|
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
|
||||||
|
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
|
||||||
|
} else {
|
||||||
|
devicesHtml = devices.map((d) => {
|
||||||
|
const isCurrent = d.name === currentDeviceName;
|
||||||
|
return `
|
||||||
|
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
|
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
|
||||||
|
</div>
|
||||||
|
${isCurrent ? '' : `
|
||||||
|
<button
|
||||||
|
class="btn sec-revoke-btn"
|
||||||
|
data-device-name="${escapeHtml(d.name)}"
|
||||||
|
style="font-size:11px; margin-left:8px; flex-shrink:0;"
|
||||||
|
>revoke</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Assemble ---
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="settings-section" style="margin-top:0;">
|
||||||
|
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
||||||
|
Recovery QR
|
||||||
|
</div>
|
||||||
|
${qrStatusHtml}
|
||||||
|
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section" style="margin-top:16px;">
|
||||||
|
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
||||||
|
Trusted Devices
|
||||||
|
</div>
|
||||||
|
<div id="sec-devices-list">
|
||||||
|
${devicesHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// --- Wire handlers ---
|
||||||
|
|
||||||
|
const setQrError = (msg: string): void => {
|
||||||
|
const el = document.getElementById('sec-qr-error');
|
||||||
|
if (el) el.textContent = msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function doGenerateQr(isRegen: boolean): Promise<void> {
|
||||||
|
const passphrase = prompt(
|
||||||
|
isRegen
|
||||||
|
? 'Enter your vault passphrase to regenerate the recovery QR:'
|
||||||
|
: 'Enter your vault passphrase to generate the recovery QR:',
|
||||||
|
);
|
||||||
|
if (!passphrase) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||||
|
|
||||||
|
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
||||||
|
if (!resp.ok) {
|
||||||
|
setQrError(`Failed: ${resp.error}`);
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = (resp.data as { svg: string }).svg;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Store only the timestamp, NEVER the QR payload
|
||||||
|
await chrome.storage.local.set({ recovery_qr_generated_at: now });
|
||||||
|
|
||||||
|
showQrModal(svg);
|
||||||
|
|
||||||
|
// Re-render to reflect new state (timestamp now exists)
|
||||||
|
await renderSecuritySection(container, sessionHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
|
||||||
|
void doGenerateQr(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
|
||||||
|
void doGenerateQr(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
|
||||||
|
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
|
||||||
|
if (!passphrase) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
||||||
|
|
||||||
|
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
||||||
|
if (!resp.ok) {
|
||||||
|
setQrError(`Failed: ${resp.error}`);
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
||||||
|
const svg = (resp.data as { svg: string }).svg;
|
||||||
|
showQrModal(svg);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke buttons
|
||||||
|
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const name = btn.dataset.deviceName;
|
||||||
|
if (!name) return;
|
||||||
|
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
|
||||||
|
const result = await sendMessage({ type: 'revoke_device', name });
|
||||||
|
if (result.ok) {
|
||||||
|
await sendMessage({ type: 'sync' });
|
||||||
|
// Re-render to refresh device list
|
||||||
|
await renderSecuritySection(container, sessionHandle);
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'revoke';
|
||||||
|
setQrError(`Revoke failed: ${result.error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardownSecuritySection(): void {
|
||||||
|
removeModal();
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
|
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||||
|
import { GLYPH_NEXT } from '../../shared/glyphs';
|
||||||
|
|
||||||
let pendingSettings: VaultSettings | null = null;
|
let pendingSettings: VaultSettings | null = null;
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
@@ -161,14 +162,14 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section__title">backup & restore</div>
|
<div class="settings-section__title">backup & restore</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<button class="btn" id="open-backup">Backup & restore →</button>
|
<button class="btn" id="open-backup">Backup & restore ${GLYPH_NEXT}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="settings-section__title">import</div>
|
<div class="settings-section__title">import</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<button class="btn" id="open-import">LastPass CSV →</button>
|
<button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
||||||
import type { DeviceSettings } from '../../shared/types';
|
import type { DeviceSettings } from '../../shared/types';
|
||||||
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../../shared/color-scheme';
|
||||||
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
|
||||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
export async function renderSettings(app: HTMLElement): Promise<void> {
|
||||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
||||||
@@ -62,6 +67,9 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
|||||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;" id="display-section-container">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
||||||
<div id="blacklist-container">
|
<div id="blacklist-container">
|
||||||
@@ -119,4 +127,65 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Render Display section after the rest of the DOM is ready
|
||||||
|
await renderDisplaySection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
|
||||||
|
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
|
||||||
|
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
|
||||||
|
swatch.innerHTML = '';
|
||||||
|
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDisplaySection(): Promise<void> {
|
||||||
|
// The Display section container must be present in the DOM before we call this
|
||||||
|
const container = document.getElementById('display-section-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||||
|
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||||
|
digit color
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
||||||
|
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||||
|
symbol color
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="display-swatch" class="color-preview-swatch"></div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
|
||||||
|
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
|
||||||
|
const swatch = document.getElementById('display-swatch') as HTMLElement;
|
||||||
|
|
||||||
|
// Render initial swatch
|
||||||
|
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
|
||||||
|
|
||||||
|
async function onColorChange(): Promise<void> {
|
||||||
|
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
|
||||||
|
await saveColorScheme(newScheme);
|
||||||
|
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
digitInput.addEventListener('change', () => void onColorChange());
|
||||||
|
symbolInput.addEventListener('change', () => void onColorChange());
|
||||||
|
|
||||||
|
document.getElementById('display-reset')?.addEventListener('click', async () => {
|
||||||
|
await resetColorScheme();
|
||||||
|
digitInput.value = DEFAULT_DIGIT_COLOR;
|
||||||
|
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
||||||
|
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ vi.mock('../../../../setup/setup-helpers', () => ({
|
|||||||
entropyText: vi.fn(() => ''),
|
entropyText: vi.fn(() => ''),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { renderForm } from '../login';
|
import { renderForm, applyGeneratedPassword } from '../login';
|
||||||
import { sendMessage } from '../../../../shared/state';
|
import { sendMessage } from '../../../../shared/state';
|
||||||
|
|
||||||
describe('login form smart inputs', () => {
|
describe('login form smart inputs', () => {
|
||||||
@@ -63,6 +63,40 @@ describe('login form smart inputs', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('renderForm surface flag', () => {
|
||||||
|
let app: HTMLElement;
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
|
app = document.getElementById('app')!;
|
||||||
|
(globalThis as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
|
||||||
|
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders single-column when surface is "popup" (default)', () => {
|
||||||
|
renderForm(app, 'add', null);
|
||||||
|
expect(app.querySelector('.form-grid')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders two-column .form-grid wrapper when surface is "fullscreen"', () => {
|
||||||
|
renderForm(app, 'add', null, { surface: 'fullscreen' });
|
||||||
|
const grid = app.querySelector('.form-grid');
|
||||||
|
expect(grid).toBeTruthy();
|
||||||
|
expect(grid!.querySelector('[data-form-section="identity"]')).toBeTruthy();
|
||||||
|
expect(grid!.querySelector('[data-form-section="credentials"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Login save shape', () => {
|
describe('Login save shape', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = '<div id="app"></div>';
|
document.body.innerHTML = '<div id="app"></div>';
|
||||||
@@ -120,3 +154,37 @@ describe('Login save shape', () => {
|
|||||||
expect(addCall).toBeUndefined();
|
expect(addCall).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('regenerate handler dispatches input event', () => {
|
||||||
|
it('dispatches an InputEvent on the input after value is set', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'password';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const dispatchSpy = vi.spyOn(input, 'dispatchEvent');
|
||||||
|
|
||||||
|
applyGeneratedPassword(input, 'sCMtTJkF%GN^mF#-N6D%');
|
||||||
|
|
||||||
|
expect(input.value).toBe('sCMtTJkF%GN^mF#-N6D%');
|
||||||
|
expect(input.type).toBe('text');
|
||||||
|
expect(dispatchSpy).toHaveBeenCalled();
|
||||||
|
const evt = dispatchSpy.mock.calls.find(c => c[0] instanceof InputEvent)?.[0] as InputEvent;
|
||||||
|
expect(evt).toBeDefined();
|
||||||
|
expect(evt.type).toBe('input');
|
||||||
|
expect(evt.bubbles).toBe(true);
|
||||||
|
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bubbling listener fires when applyGeneratedPassword is called', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
let listenerFired = false;
|
||||||
|
input.addEventListener('input', () => { listenerFired = true; });
|
||||||
|
applyGeneratedPassword(input, 'newpass');
|
||||||
|
expect(listenerFired).toBe(true);
|
||||||
|
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/to
|
|||||||
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
||||||
import { scheduleRate } from '../../../setup/setup-helpers';
|
import { scheduleRate } from '../../../setup/setup-helpers';
|
||||||
|
|
||||||
|
/// Sets a generated password on an input, reveals it as plain text, then
|
||||||
|
/// dispatches a synthetic InputEvent so listeners (e.g. the strength meter)
|
||||||
|
/// re-evaluate the new value.
|
||||||
|
export function applyGeneratedPassword(input: HTMLInputElement, value: string): void {
|
||||||
|
input.value = value;
|
||||||
|
input.type = 'text';
|
||||||
|
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
/// Called by the dispatcher before each render. Stops any in-flight
|
/// Called by the dispatcher before each render. Stops any in-flight
|
||||||
/// tickers / intervals / listeners the previous view may have attached.
|
/// tickers / intervals / listeners the previous view may have attached.
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
@@ -75,7 +84,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${renderSignatureBlock({ accent: 'gold', children: sigInner })}
|
${renderSignatureBlock({ accent: 'gold', children: sigInner })}
|
||||||
</div>
|
</div>
|
||||||
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
|
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
|
||||||
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })}
|
${renderConcealedRow({ id: 'login-password', label: 'password', value: password, kind: 'password' })}
|
||||||
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
|
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
|
||||||
${hasTotp ? `
|
${hasTotp ? `
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
@@ -235,7 +244,20 @@ function startTotpTicker(id: ItemId): void {
|
|||||||
// Form (add / edit)
|
// Form (add / edit)
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
export interface RenderFormOptions {
|
||||||
|
surface?: 'popup' | 'fullscreen';
|
||||||
|
/** When true, renderForm skips its own save/cancel buttons (caller provides them in a sticky bar). */
|
||||||
|
externalActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderForm(
|
||||||
|
app: HTMLElement,
|
||||||
|
mode: 'add' | 'edit',
|
||||||
|
existing: Item | null,
|
||||||
|
opts: RenderFormOptions = {}
|
||||||
|
): void {
|
||||||
|
const surface = opts.surface ?? 'popup';
|
||||||
|
const externalActions = opts.externalActions ?? false;
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const existingCore = (existing?.core.type === 'login')
|
const existingCore = (existing?.core.type === 'login')
|
||||||
? (existing.core as LoginCore & { type: 'login' })
|
? (existing.core as LoginCore & { type: 'login' })
|
||||||
@@ -254,72 +276,102 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
: [];
|
: [];
|
||||||
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||||
|
|
||||||
|
const titleFieldHtml = `
|
||||||
|
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
||||||
|
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>`;
|
||||||
|
|
||||||
|
const urlFieldHtml = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-url">url</label>
|
||||||
|
<div class="inline-row">
|
||||||
|
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||||
|
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||||
|
</div>
|
||||||
|
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const groupFieldHtml = `
|
||||||
|
<div class="form-group"><label class="label" for="f-group">group</label>
|
||||||
|
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>`;
|
||||||
|
|
||||||
|
const usernameFieldHtml = `
|
||||||
|
<div class="form-group"><label class="label" for="f-username">username</label>
|
||||||
|
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>`;
|
||||||
|
|
||||||
|
const passwordFieldHtml = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-password">password</label>
|
||||||
|
<div class="inline-row">
|
||||||
|
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||||
|
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||||
|
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
||||||
|
</div>
|
||||||
|
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||||
|
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||||
|
<div class="strength-label"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const totpFieldHtml = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||||
|
<div class="inline-row">
|
||||||
|
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||||
|
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
||||||
|
</div>
|
||||||
|
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||||
|
<span class="totp-code">…</span>
|
||||||
|
<span class="totp-countdown">…</span>
|
||||||
|
</div>
|
||||||
|
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||||
|
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||||
|
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
||||||
|
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const identityHtml = `
|
||||||
|
<div data-form-section="identity" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
|
||||||
|
${surface === 'fullscreen' ? '<div class="col-header">Identity</div>' : ''}
|
||||||
|
${titleFieldHtml}
|
||||||
|
${urlFieldHtml}
|
||||||
|
${groupFieldHtml}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const credentialsHtml = `
|
||||||
|
<div data-form-section="credentials" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
|
||||||
|
${surface === 'fullscreen' ? '<div class="col-header">Credentials</div>' : ''}
|
||||||
|
${usernameFieldHtml}
|
||||||
|
${passwordFieldHtml}
|
||||||
|
${totpFieldHtml}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const sectionsHtml = surface === 'fullscreen'
|
||||||
|
? `<div class="form-grid">${identityHtml}${credentialsHtml}</div>`
|
||||||
|
: `${identityHtml}${credentialsHtml}`;
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
|
${surface === 'popup' ? renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' }) : ''}
|
||||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
|
||||||
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
${sectionsHtml}
|
||||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="${surface === 'fullscreen' ? 'form-lower' : ''}">
|
||||||
<label class="label" for="f-url">url</label>
|
<div class="form-group">
|
||||||
<div class="inline-row">
|
<div class="notes-with-toggle">
|
||||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
||||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group"><label class="label" for="f-username">username</label>
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>
|
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||||
|
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
|
||||||
<div class="form-group">
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<label class="label" for="f-password">password</label>
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||||
<div class="inline-row">
|
|
||||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
|
||||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
|
||||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
|
||||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
|
||||||
<div class="strength-label"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
|
||||||
<div class="inline-row">
|
|
||||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
|
||||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
|
||||||
</div>
|
|
||||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
|
||||||
<span class="totp-code">…</span>
|
|
||||||
<span class="totp-countdown">…</span>
|
|
||||||
</div>
|
|
||||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
|
||||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
|
||||||
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
|
||||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group"><label class="label" for="f-group">group</label>
|
|
||||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="notes-with-toggle">
|
|
||||||
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
|
||||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
|
||||||
</div>
|
|
||||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
|
||||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
|
||||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -392,7 +444,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
context: 'fill-field',
|
context: 'fill-field',
|
||||||
onPicked: (value) => {
|
onPicked: (value) => {
|
||||||
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
const pw = document.getElementById('f-password') as HTMLInputElement | null;
|
||||||
if (pw) { pw.value = value; pw.type = 'text'; }
|
if (pw) applyGeneratedPassword(pw, value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -433,7 +485,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
export async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||||
|
|||||||
@@ -7,54 +7,63 @@ export function renderUnlock(app: HTMLElement): void {
|
|||||||
const state = getState();
|
const state = getState();
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad" style="text-align:center; padding-top:40px;">
|
<div class="pad" style="text-align:center; padding-top:32px;">
|
||||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
<div class="logo-lockup" style="margin-bottom:24px;">
|
||||||
<div class="brand">Relicario</div>
|
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
|
||||||
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p>
|
<div class="brand">Relicario</div>
|
||||||
<div class="form-group">
|
<p class="tagline">two-factor vault</p>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="passphrase-input"
|
|
||||||
placeholder="passphrase"
|
|
||||||
autocomplete="off"
|
|
||||||
${state.loading ? 'disabled' : ''}
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
|
|
||||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
<div class="glass" style="padding:16px; text-align:left; margin-bottom:16px;">
|
||||||
<div style="margin-top:24px;">
|
<div class="card-label" style="font-size:10px;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-muted);margin-bottom:8px;">unlock</div>
|
||||||
<button class="btn" id="vault-btn" style="font-size:11px;">open vault</button>
|
<div class="form-group" style="margin-bottom:10px;">
|
||||||
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
<input
|
||||||
|
type="password"
|
||||||
|
id="passphrase-input"
|
||||||
|
placeholder="passphrase"
|
||||||
|
autocomplete="off"
|
||||||
|
${state.loading ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
|
||||||
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
<button class="btn-primary" id="unlock-btn" style="width:100%;justify-content:center;" ${state.loading ? 'disabled' : ''}>unlock vault</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; justify-content:center;">
|
||||||
|
<button class="btn-secondary" id="vault-btn">open vault</button>
|
||||||
|
<button class="btn-secondary" id="settings-btn">settings</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const input = document.getElementById('passphrase-input') as HTMLInputElement;
|
const input = document.getElementById('passphrase-input') as HTMLInputElement;
|
||||||
|
const unlockBtn = document.getElementById('unlock-btn') as HTMLButtonElement | null;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const passphrase = input.value;
|
||||||
|
if (!passphrase) return;
|
||||||
|
setState({ loading: true, error: null });
|
||||||
|
const resp = await sendMessage({ type: 'unlock', passphrase });
|
||||||
|
if (resp.ok) {
|
||||||
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
|
if (listResp.ok) {
|
||||||
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
||||||
|
navigate('list', { entries: data.items });
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: listResp.error });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState({ loading: false, error: resp.error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (input && !state.loading) {
|
if (input && !state.loading) {
|
||||||
input.focus();
|
input.focus();
|
||||||
input.addEventListener('keydown', async (e) => {
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const passphrase = input.value;
|
|
||||||
if (!passphrase) return;
|
|
||||||
setState({ loading: true, error: null });
|
|
||||||
const resp = await sendMessage({ type: 'unlock', passphrase });
|
|
||||||
if (resp.ok) {
|
|
||||||
const listResp = await sendMessage({ type: 'list_items' });
|
|
||||||
if (listResp.ok) {
|
|
||||||
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
|
||||||
navigate('list', { entries: data.items });
|
|
||||||
} else {
|
|
||||||
setState({ loading: false, error: listResp.error });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setState({ loading: false, error: resp.error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
unlockBtn?.addEventListener('click', submit);
|
||||||
|
|
||||||
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
|
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
|
||||||
|
document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));
|
||||||
const settingsBtn = document.getElementById('settings-btn');
|
|
||||||
settingsBtn?.addEventListener('click', () => navigate('settings'));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<title>Relicario</title>
|
<title>Relicario</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="surface-backdrop">
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
/// Navigation works by updating `currentState` and calling `render()`.
|
/// Navigation works by updating `currentState` and calling `render()`.
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
|
import { lookupErrorCopy } from '../shared/error-copy';
|
||||||
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
import type { ItemId, ManifestEntry, Item } from '../shared/types';
|
||||||
import { registerHost } from '../shared/state';
|
import { registerHost } from '../shared/state';
|
||||||
import { renderUnlock } from './components/unlock';
|
import { renderUnlock } from './components/unlock';
|
||||||
@@ -18,6 +19,7 @@ import { renderFieldHistory } from './components/field-history';
|
|||||||
import { teardown as teardownTrash } from './components/trash';
|
import { teardown as teardownTrash } from './components/trash';
|
||||||
import { teardown as teardownDevices } from './components/devices';
|
import { teardown as teardownDevices } from './components/devices';
|
||||||
import { teardown as teardownFieldHistory } from './components/field-history';
|
import { teardown as teardownFieldHistory } from './components/field-history';
|
||||||
|
import { applyColorScheme } from '../shared/color-scheme';
|
||||||
|
|
||||||
// --- Escape HTML to prevent XSS ---
|
// --- Escape HTML to prevent XSS ---
|
||||||
export function escapeHtml(str: string): string {
|
export function escapeHtml(str: string): string {
|
||||||
@@ -144,19 +146,8 @@ export function humanizeError(err: string): string {
|
|||||||
if (/settings json:/i.test(err)) {
|
if (/settings json:/i.test(err)) {
|
||||||
return 'Settings are in an invalid format — try reloading the extension.';
|
return 'Settings are in an invalid format — try reloading the extension.';
|
||||||
}
|
}
|
||||||
if (/vault_locked/i.test(err)) {
|
const copy = lookupErrorCopy(err);
|
||||||
return 'Vault is locked. Unlock and try again.';
|
return copy.body;
|
||||||
}
|
|
||||||
if (/origin_mismatch/i.test(err)) {
|
|
||||||
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
|
|
||||||
}
|
|
||||||
if (/unauthorized_sender/i.test(err)) {
|
|
||||||
return 'This action is not allowed from here.';
|
|
||||||
}
|
|
||||||
if (/tab_navigated|captured_tab_gone/i.test(err)) {
|
|
||||||
return 'The browser tab changed before the fill could complete — try again.';
|
|
||||||
}
|
|
||||||
return err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
@@ -225,6 +216,14 @@ function render(): void {
|
|||||||
// --- Init ---
|
// --- Init ---
|
||||||
|
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
|
await applyColorScheme();
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && 'password_display_scheme' in changes) {
|
||||||
|
void applyColorScheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Snapshot the active tab at popup-open — the fill path uses this
|
// Snapshot the active tab at popup-open — the fill path uses this
|
||||||
// tabId/url pair so the SW can verify the tab hasn't navigated before
|
// tabId/url pair so the SW can verify the tab hasn't navigated before
|
||||||
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
|
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
/* Relicario extension — terminal dark theme */
|
/* Relicario extension — terminal dark theme */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Brand */
|
/* Patina gold (Phase 2B) */
|
||||||
--accent: #d2ab43;
|
--gold-base: #a88a4a;
|
||||||
--accent-soft: rgba(210, 171, 67, 0.18);
|
--gold-mid: #cdb47a;
|
||||||
--accent-strong: #aa812a;
|
--gold-shadow: #5a3f12;
|
||||||
|
--gold-text: #c9a868;
|
||||||
|
--gold-soft: rgba(184, 149, 86, 0.14);
|
||||||
|
--gold-ring: rgba(184, 149, 86, 0.18);
|
||||||
|
--gold-stroke: #b89556;
|
||||||
|
--gold-hi-end: #dac8a0;
|
||||||
|
|
||||||
|
/* Brand alias (kept for backwards compatibility) */
|
||||||
|
--accent: var(--gold-base);
|
||||||
|
--accent-soft: var(--gold-soft);
|
||||||
|
--accent-strong: var(--gold-shadow);
|
||||||
|
|
||||||
/* Surfaces */
|
/* Surfaces */
|
||||||
--bg-page: #0d1117;
|
--bg-page: #0a0e14;
|
||||||
--bg-pane: #161b22;
|
--bg-pane: #11161e;
|
||||||
--bg-elevated: #21262d;
|
--bg-elevated: #1c2330;
|
||||||
--bg-input: #161b22;
|
--bg-card: rgba(22, 27, 34, 0.55);
|
||||||
--border-subtle: #30363d;
|
--bg-input: #0a0e14;
|
||||||
|
--border-soft: rgba(255, 255, 255, 0.05);
|
||||||
|
--border-mid: #262d36;
|
||||||
|
--border-subtle: var(--border-mid);
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text: #c9d1d9;
|
--text: #c9d1d9;
|
||||||
--text-muted: #8b949e;
|
--text-muted: #8b949e;
|
||||||
--text-dim: #484f58;
|
--text-dim: #6b7888;
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--danger: #ab2b20;
|
--danger: #ab2b20;
|
||||||
@@ -24,7 +37,11 @@
|
|||||||
--success: #6cb37a;
|
--success: #6cb37a;
|
||||||
|
|
||||||
/* Focus */
|
/* Focus */
|
||||||
--focus-ring: 0 0 0 2px rgba(210, 171, 67, 0.35);
|
--focus-ring: 0 0 0 2px var(--gold-ring);
|
||||||
|
|
||||||
|
/* Password coloring (P1) */
|
||||||
|
--relicario-pwd-digit-color: #2563eb;
|
||||||
|
--relicario-pwd-symbol-color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -37,7 +54,7 @@ body {
|
|||||||
width: 360px;
|
width: 360px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0d1117;
|
background: var(--bg-page);
|
||||||
color: #c9d1d9;
|
color: #c9d1d9;
|
||||||
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
|
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -62,7 +79,7 @@ body {
|
|||||||
.brand {
|
.brand {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #d2ab43;
|
color: var(--gold-text);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +424,41 @@ textarea {
|
|||||||
background: #aa812a;
|
background: #aa812a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Setup wizard — Style C progress track */
|
||||||
|
.setup-progress-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 8px auto 16px;
|
||||||
|
}
|
||||||
|
.setup-progress-segment {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.setup-progress-segment--completed { background: var(--success, #238636); }
|
||||||
|
.setup-progress-segment--active { background: var(--gold, #b8860b); }
|
||||||
|
.setup-progress-segment--pending { background: var(--border, #30363d); }
|
||||||
|
|
||||||
|
/* Setup wizard — Recovery QR banner */
|
||||||
|
.recovery-qr-banner {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-elevated, #161b22);
|
||||||
|
border: 1px solid var(--gold, #b8860b);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.recovery-qr-banner__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.recovery-qr-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Spinner */
|
/* Spinner */
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1457,3 +1509,102 @@ textarea {
|
|||||||
.f-notes--mono {
|
.f-notes--mono {
|
||||||
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
|
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 2B: surface backdrop — subtle radial top-glow + grid texture.
|
||||||
|
Apply to body or a top-level wrapper. Children must sit above the ::before. */
|
||||||
|
.surface-backdrop {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 700px 240px at 50% -40px, rgba(184, 149, 86, 0.05), transparent 65%),
|
||||||
|
linear-gradient(180deg, #11161e 0%, #0a0e14 100%);
|
||||||
|
}
|
||||||
|
.surface-backdrop::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.012) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.012) 1px, transparent 1px);
|
||||||
|
background-size: 18px 18px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.surface-backdrop > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phase 2B: glass card. Translucent panel with backdrop blur for the
|
||||||
|
unlock card, setup step card, and form section panels. Falls back
|
||||||
|
gracefully on browsers without backdrop-filter (just stays translucent). */
|
||||||
|
.glass {
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.03) inset,
|
||||||
|
0 6px 18px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phase 2B: button hierarchy. Existing .btn class kept for backwards
|
||||||
|
compatibility; .btn-primary and .btn-secondary express clearer intent
|
||||||
|
and are used in updated views. */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--gold-base);
|
||||||
|
color: var(--bg-page);
|
||||||
|
border: none;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--gold-stroke); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { border-color: rgba(255, 255, 255, 0.12); color: var(--text); }
|
||||||
|
.btn-secondary:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; }
|
||||||
|
.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; }
|
||||||
|
.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; }
|
||||||
|
|
||||||
|
/* Password character-class coloring */
|
||||||
|
.pwd-digit { color: var(--relicario-pwd-digit-color); }
|
||||||
|
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
|
||||||
|
.pwd-letter { color: inherit; }
|
||||||
|
|
||||||
|
.color-preview-swatch {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
/// Device management — reads/writes .relicario/devices.json
|
/// Device management — reads/writes .relicario/devices.json and revoked.json
|
||||||
|
|
||||||
import type { GitHost } from './git-host';
|
import type { GitHost } from './git-host';
|
||||||
import type { Device } from '../shared/types';
|
import type { Device } from '../shared/types';
|
||||||
|
|
||||||
const DEVICES_PATH = '.relicario/devices.json';
|
const DEVICES_PATH = '.relicario/devices.json';
|
||||||
|
const REVOKED_PATH = '.relicario/revoked.json';
|
||||||
|
|
||||||
interface DevicesFile {
|
interface DevicesFile {
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RevokedEntry {
|
||||||
|
name: string;
|
||||||
|
public_key: string;
|
||||||
|
revoked_at: number; // unix timestamp
|
||||||
|
revoked_by: string; // name of device that performed the revocation
|
||||||
|
}
|
||||||
|
|
||||||
export async function readDevices(gitHost: GitHost): Promise<Device[]> {
|
export async function readDevices(gitHost: GitHost): Promise<Device[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await gitHost.readFile(DEVICES_PATH);
|
const raw = await gitHost.readFile(DEVICES_PATH);
|
||||||
@@ -30,6 +38,25 @@ export async function writeDevices(
|
|||||||
await gitHost.writeFile(DEVICES_PATH, bytes, message);
|
await gitHost.writeFile(DEVICES_PATH, bytes, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readRevoked(gitHost: GitHost): Promise<RevokedEntry[]> {
|
||||||
|
try {
|
||||||
|
const raw = await gitHost.readFile(REVOKED_PATH);
|
||||||
|
const text = new TextDecoder().decode(raw);
|
||||||
|
return JSON.parse(text) as RevokedEntry[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRevoked(
|
||||||
|
gitHost: GitHost,
|
||||||
|
revoked: RevokedEntry[],
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(revoked, null, 2));
|
||||||
|
await gitHost.writeFile(REVOKED_PATH, bytes, message);
|
||||||
|
}
|
||||||
|
|
||||||
export async function addDevice(
|
export async function addDevice(
|
||||||
gitHost: GitHost,
|
gitHost: GitHost,
|
||||||
device: Device,
|
device: Device,
|
||||||
@@ -45,11 +72,25 @@ export async function addDevice(
|
|||||||
export async function revokeDevice(
|
export async function revokeDevice(
|
||||||
gitHost: GitHost,
|
gitHost: GitHost,
|
||||||
name: string,
|
name: string,
|
||||||
|
revokedBy?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await readDevices(gitHost);
|
const existing = await readDevices(gitHost);
|
||||||
const filtered = existing.filter((d) => d.name !== name);
|
const device = existing.find((d) => d.name === name);
|
||||||
if (filtered.length === existing.length) {
|
if (!device) {
|
||||||
throw new Error(`device '${name}' not found`);
|
throw new Error(`device '${name}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove from devices.json
|
||||||
|
const filtered = existing.filter((d) => d.name !== name);
|
||||||
await writeDevices(gitHost, filtered, `device: revoke ${name}`);
|
await writeDevices(gitHost, filtered, `device: revoke ${name}`);
|
||||||
|
|
||||||
|
// Add to revoked.json
|
||||||
|
const revoked = await readRevoked(gitHost);
|
||||||
|
revoked.push({
|
||||||
|
name,
|
||||||
|
public_key: device.public_key,
|
||||||
|
revoked_at: Math.floor(Date.now() / 1000),
|
||||||
|
revoked_by: revokedBy ?? 'unknown',
|
||||||
|
});
|
||||||
|
await writeRevoked(gitHost, revoked, `device: revoke ${name} (revoked log)`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class GiteaHost implements GitHost {
|
|||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private gitApiBase: string;
|
private gitApiBase: string;
|
||||||
private commitsUrl: string;
|
private commitsUrl: string;
|
||||||
|
private keysUrl: string;
|
||||||
private branch: string = 'main';
|
private branch: string = 'main';
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export class GiteaHost implements GitHost {
|
|||||||
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
|
this.baseUrl = `${apiUrl}/repos/${repoPath}/contents`;
|
||||||
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
|
this.gitApiBase = `${apiUrl}/repos/${repoPath}/git`;
|
||||||
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
|
this.commitsUrl = `${apiUrl}/repos/${repoPath}/commits`;
|
||||||
|
this.keysUrl = `${apiUrl}/repos/${repoPath}/keys`;
|
||||||
this.headers = {
|
this.headers = {
|
||||||
'Authorization': `token ${apiToken}`,
|
'Authorization': `token ${apiToken}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -244,4 +246,31 @@ export class GiteaHost implements GitHost {
|
|||||||
async deleteBlob(path: string, message: string): Promise<void> {
|
async deleteBlob(path: string, message: string): Promise<void> {
|
||||||
return this.deleteFile(path, message);
|
return this.deleteFile(path, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a deploy key for this repo, returning its numeric ID.
|
||||||
|
async createDeployKey(title: string, publicKey: string): Promise<number> {
|
||||||
|
const resp = await fetch(this.keysUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers,
|
||||||
|
body: JSON.stringify({ title, key: publicKey, read_only: false }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`createDeployKey: ${resp.status} ${text}`);
|
||||||
|
}
|
||||||
|
const json = await resp.json() as { id: number };
|
||||||
|
return json.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a deploy key by numeric ID. Ignores 404 (already gone).
|
||||||
|
async deleteDeployKey(keyId: number): Promise<void> {
|
||||||
|
const resp = await fetch(`${this.keysUrl}/${keyId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: this.headers,
|
||||||
|
});
|
||||||
|
if (!resp.ok && resp.status !== 404) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`deleteDeployKey: ${resp.status} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,12 @@ export async function handle(
|
|||||||
return { ok: true, data: { devices: list } };
|
return { ok: true, data: { devices: list } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'list_revoked': {
|
||||||
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
|
const revoked = await devices.readRevoked(state.gitHost);
|
||||||
|
return { ok: true, data: { revoked } };
|
||||||
|
}
|
||||||
|
|
||||||
case 'add_device': {
|
case 'add_device': {
|
||||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
const device = {
|
const device = {
|
||||||
@@ -359,17 +365,15 @@ export async function handle(
|
|||||||
|
|
||||||
case 'register_this_device': {
|
case 'register_this_device': {
|
||||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
const keypair = state.wasm.generate_device_keypair() as {
|
// register_device keeps private keys internal — only public keys cross to JS
|
||||||
public_key_hex: string;
|
const keys = state.wasm.register_device(msg.name) as {
|
||||||
private_key_base64: string;
|
signing_public_key: string;
|
||||||
|
deploy_public_key: string;
|
||||||
};
|
};
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({ device_name: msg.name });
|
||||||
device_name: msg.name,
|
|
||||||
device_private_key: keypair.private_key_base64,
|
|
||||||
});
|
|
||||||
await devices.addDevice(state.gitHost, {
|
await devices.addDevice(state.gitHost, {
|
||||||
name: msg.name,
|
name: msg.name,
|
||||||
public_key: keypair.public_key_hex,
|
public_key: keys.signing_public_key,
|
||||||
added_at: Math.floor(Date.now() / 1000),
|
added_at: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -377,7 +381,9 @@ export async function handle(
|
|||||||
|
|
||||||
case 'revoke_device': {
|
case 'revoke_device': {
|
||||||
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
await devices.revokeDevice(state.gitHost, msg.name);
|
const stored = await chrome.storage.local.get(['device_name']);
|
||||||
|
const revokedBy = stored.device_name as string | undefined;
|
||||||
|
await devices.revokeDevice(state.gitHost, msg.name, revokedBy);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +574,26 @@ export async function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'generate_recovery_qr': {
|
||||||
|
const handle = session.getCurrent();
|
||||||
|
if (!handle) return { ok: false, error: 'vault_locked' };
|
||||||
|
try {
|
||||||
|
const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
|
||||||
|
return { ok: true, data: { svg } };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unwrap_recovery_qr': {
|
||||||
|
try {
|
||||||
|
const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
|
||||||
|
return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'import_lastpass_commit': {
|
case 'import_lastpass_commit': {
|
||||||
const handle = session.getCurrent();
|
const handle = session.getCurrent();
|
||||||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||||||
|
|||||||
37
extension/src/setup/__tests__/setup.test.ts
Normal file
37
extension/src/setup/__tests__/setup.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { finishSetup } from '../setup';
|
||||||
|
|
||||||
|
describe('finishSetup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(global as any).chrome = {
|
||||||
|
tabs: {
|
||||||
|
create: vi.fn(() => Promise.resolve({ id: 999 })),
|
||||||
|
getCurrent: vi.fn(() => Promise.resolve({ id: 42 })),
|
||||||
|
remove: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
getURL: vi.fn((p: string) => `chrome-extension://abc/${p}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens vault.html in a new tab', async () => {
|
||||||
|
await finishSetup();
|
||||||
|
expect(chrome.runtime.getURL).toHaveBeenCalledWith('vault.html');
|
||||||
|
expect(chrome.tabs.create).toHaveBeenCalledWith({
|
||||||
|
url: 'chrome-extension://abc/vault.html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the current setup tab after opening the vault tab', async () => {
|
||||||
|
await finishSetup();
|
||||||
|
expect(chrome.tabs.getCurrent).toHaveBeenCalled();
|
||||||
|
expect(chrome.tabs.remove).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still opens the vault tab even if closing the setup tab fails', async () => {
|
||||||
|
(chrome.tabs.remove as any).mockRejectedValueOnce(new Error('no permission'));
|
||||||
|
await expect(finishSetup()).resolves.not.toThrow();
|
||||||
|
expect(chrome.tabs.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
STRENGTH_LABELS,
|
STRENGTH_LABELS,
|
||||||
entropyText,
|
entropyText,
|
||||||
} from './setup-helpers';
|
} from './setup-helpers';
|
||||||
|
import { GLYPH_NEXT } from '../shared/glyphs';
|
||||||
import type { VaultConfig } from '../shared/types';
|
import type { VaultConfig } from '../shared/types';
|
||||||
import type { SessionHandle } from 'relicario-wasm';
|
import type { SessionHandle } from 'relicario-wasm';
|
||||||
|
|
||||||
@@ -92,6 +93,17 @@ const state: WizardState = {
|
|||||||
deviceName: '',
|
deviceName: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Progress track ---
|
||||||
|
|
||||||
|
const SETUP_STEP_NAMES = ['mode', 'host', 'connection', 'vault', 'device', 'done'];
|
||||||
|
|
||||||
|
function renderProgressTrack(current: number): string {
|
||||||
|
return `<div class="setup-progress-track">${SETUP_STEP_NAMES.map((_, i) => {
|
||||||
|
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
|
||||||
|
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${SETUP_STEP_NAMES[i]}"></div>`;
|
||||||
|
}).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
|
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
|
||||||
|
|
||||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
/// Update just the meter DOM without a full re-render (so the input keeps
|
||||||
@@ -167,16 +179,7 @@ function render(): void {
|
|||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
|
||||||
const progressHtml = `
|
const progressHtml = renderProgressTrack(state.step);
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
|
|
||||||
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
|
|
||||||
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
|
|
||||||
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
|
|
||||||
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
|
|
||||||
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
let stepHtml = '';
|
let stepHtml = '';
|
||||||
switch (state.step) {
|
switch (state.step) {
|
||||||
@@ -189,12 +192,14 @@ function render(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad" style="padding-top:12px;">
|
<div class="surface-backdrop" style="min-height:100vh;">
|
||||||
<img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;">
|
<div class="pad" style="padding-top:12px;">
|
||||||
<div class="brand" style="margin-bottom:4px;">Relicario vault setup</div>
|
<img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;">
|
||||||
${progressHtml}
|
<div class="brand" style="margin-bottom:4px;">Relicario vault setup</div>
|
||||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
${progressHtml}
|
||||||
${stepHtml}
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||||
|
${stepHtml}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -214,20 +219,22 @@ function renderStep0(): string {
|
|||||||
const isNew = state.mode === 'new';
|
const isNew = state.mode === 'new';
|
||||||
const isAttach = state.mode === 'attach';
|
const isAttach = state.mode === 'attach';
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<h3>set up Relicario</h3>
|
<h3>set up Relicario</h3>
|
||||||
<p class="muted" style="margin-bottom:16px;">
|
<p class="muted" style="margin-bottom:16px;">
|
||||||
How are you using Relicario on this device?
|
How are you using Relicario on this device?
|
||||||
</p>
|
</p>
|
||||||
<div class="mode-cards">
|
<div class="mode-cards">
|
||||||
<button class="mode-card ${isNew ? 'active' : ''}" data-mode="new">
|
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
|
||||||
|
<span class="mode-card__icon" style="font-size:28px;">◈</span>
|
||||||
<div class="mode-card-title">create new vault</div>
|
<div class="mode-card-title">create new vault</div>
|
||||||
<p class="mode-card-blurb">
|
<p class="mode-card-blurb">
|
||||||
I'm setting up Relicario for the first time. This will create a fresh
|
I'm setting up Relicario for the first time. This will create a fresh
|
||||||
encrypted vault on a new or empty git repository.
|
encrypted vault on a new or empty git repository.
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button class="mode-card ${isAttach ? 'active' : ''}" data-mode="attach">
|
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
|
||||||
|
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
|
||||||
<div class="mode-card-title">attach this device</div>
|
<div class="mode-card-title">attach this device</div>
|
||||||
<p class="mode-card-blurb">
|
<p class="mode-card-blurb">
|
||||||
I already have a vault on another device. Connect this browser to it
|
I already have a vault on another device. Connect this browser to it
|
||||||
@@ -236,7 +243,7 @@ function renderStep0(): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions" style="margin-top:24px;">
|
<div class="form-actions" style="margin-top:24px;">
|
||||||
<button class="btn btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next</button>
|
<button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -267,7 +274,7 @@ function renderStep3Attach(): string {
|
|||||||
const gateDisabled = state.attaching || !p || !hasImage;
|
const gateDisabled = state.attaching || !p || !hasImage;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<h3>attach this device</h3>
|
<h3>attach this device</h3>
|
||||||
<p class="muted" style="margin-bottom:12px;">
|
<p class="muted" style="margin-bottom:12px;">
|
||||||
Use your existing passphrase and reference image to attach this browser
|
Use your existing passphrase and reference image to attach this browser
|
||||||
@@ -430,7 +437,7 @@ function renderStep1(): string {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<h3>choose host</h3>
|
<h3>choose host</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label">host type</label>
|
<label class="label">host type</label>
|
||||||
@@ -442,7 +449,7 @@ function renderStep1(): string {
|
|||||||
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
|
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn btn-primary" id="next-btn">next</button>
|
<button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -522,7 +529,7 @@ function renderStep2(): string {
|
|||||||
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
|
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
|
||||||
const nextDisabled = !state.connectionTested || !probe || modeMismatch;
|
const nextDisabled = !state.connectionTested || !probe || modeMismatch;
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<h3>configure connection</h3>
|
<h3>configure connection</h3>
|
||||||
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
|
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
|
||||||
<label class="label" for="host-url">host url</label>
|
<label class="label" for="host-url">host url</label>
|
||||||
@@ -543,7 +550,7 @@ function renderStep2(): string {
|
|||||||
${renderProbeBanner()}
|
${renderProbeBanner()}
|
||||||
<div class="form-actions" style="margin-top:12px;">
|
<div class="form-actions" style="margin-top:12px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next</button>
|
<button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -643,7 +650,7 @@ function renderStep3New(): string {
|
|||||||
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<h3>create vault</h3>
|
<h3>create vault</h3>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -907,7 +914,7 @@ function renderStep4(): string {
|
|||||||
const defaultName = state.deviceName || `${browser} on ${os}`;
|
const defaultName = state.deviceName || `${browser} on ${os}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<h3>name this device</h3>
|
<h3>name this device</h3>
|
||||||
<p class="muted" style="margin-bottom:12px;">
|
<p class="muted" style="margin-bottom:12px;">
|
||||||
This helps you identify which devices have access to your vault.
|
This helps you identify which devices have access to your vault.
|
||||||
@@ -918,7 +925,7 @@ function renderStep4(): string {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn btn-primary" id="next-btn">continue</button>
|
<button class="btn-primary" id="next-btn">continue ${GLYPH_NEXT}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -978,8 +985,24 @@ function renderStep5(): string {
|
|||||||
const configJson = JSON.stringify(config, null, 2);
|
const configJson = JSON.stringify(config, null, 2);
|
||||||
const isAttach = state.mode === 'attach';
|
const isAttach = state.mode === 'attach';
|
||||||
|
|
||||||
|
const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? `
|
||||||
|
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
|
||||||
|
<div class="recovery-qr-banner__header">
|
||||||
|
<span style="font-size:20px;">◫</span>
|
||||||
|
<strong>Generate a recovery QR before you go</strong>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="font-size:12px;margin:4px 0 8px;">
|
||||||
|
If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.
|
||||||
|
</p>
|
||||||
|
<div class="recovery-qr-banner__actions">
|
||||||
|
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
|
||||||
|
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wizard-step">
|
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||||
<div class="success-box">
|
<div class="success-box">
|
||||||
<h3>${isAttach ? 'device verified' : 'vault created'}</h3>
|
<h3>${isAttach ? 'device verified' : 'vault created'}</h3>
|
||||||
<p class="secondary">
|
<p class="secondary">
|
||||||
@@ -989,6 +1012,8 @@ function renderStep5(): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${qrBannerHtml}
|
||||||
|
|
||||||
${isAttach ? '' : `
|
${isAttach ? '' : `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label">reference image</label>
|
<label class="label">reference image</label>
|
||||||
@@ -1023,6 +1048,48 @@ function renderStep5(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function attachStep5(): void {
|
function attachStep5(): void {
|
||||||
|
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
|
||||||
|
if (!state.verifiedHandle) return;
|
||||||
|
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
|
||||||
|
try {
|
||||||
|
const { sendMessage } = await import('../shared/state');
|
||||||
|
const resp = await sendMessage({
|
||||||
|
type: 'generate_recovery_qr',
|
||||||
|
sessionHandle: state.verifiedHandle.value,
|
||||||
|
passphrase: state.passphrase,
|
||||||
|
} as any) as any;
|
||||||
|
if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error');
|
||||||
|
const svg = (resp.data as { svg: string }).svg;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
|
||||||
|
});
|
||||||
|
const banner = document.getElementById('recovery-qr-banner');
|
||||||
|
if (banner) {
|
||||||
|
banner.innerHTML = `
|
||||||
|
<div style="text-align:center;">${svg}</div>
|
||||||
|
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">
|
||||||
|
◉ Recovery QR generated — save or print this now.
|
||||||
|
</p>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<button class="btn" id="setup-qr-done">Done</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
|
||||||
|
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
|
||||||
|
const banner = document.getElementById('recovery-qr-banner');
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
||||||
if (!state.referenceImageBytes) return;
|
if (!state.referenceImageBytes) return;
|
||||||
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||||
@@ -1049,12 +1116,12 @@ function attachStep5(): void {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const w = await loadWasm();
|
const w = await loadWasm();
|
||||||
const keypair = w.generate_device_keypair();
|
// register_device keeps private keys internal — only public keys returned
|
||||||
|
const keypair = w.register_device(state.deviceName);
|
||||||
|
|
||||||
// 1) Save private key + name locally.
|
// 1) Save device name locally (private keys stay in WASM memory).
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({
|
||||||
device_name: state.deviceName,
|
device_name: state.deviceName,
|
||||||
device_private_key: keypair.private_key_base64,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2) Save vault config + reference image to extension storage.
|
// 2) Save vault config + reference image to extension storage.
|
||||||
@@ -1086,7 +1153,7 @@ function attachStep5(): void {
|
|||||||
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
|
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
|
||||||
await addDevice(host, {
|
await addDevice(host, {
|
||||||
name: state.deviceName,
|
name: state.deviceName,
|
||||||
public_key: keypair.public_key_hex,
|
public_key: keypair.signing_public_key,
|
||||||
added_at: Math.floor(Date.now() / 1000),
|
added_at: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1098,6 +1165,7 @@ function attachStep5(): void {
|
|||||||
|
|
||||||
state.configPushed = true;
|
state.configPushed = true;
|
||||||
render();
|
render();
|
||||||
|
void finishSetup();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[relicario setup] register device failed:', err);
|
console.error('[relicario setup] register device failed:', err);
|
||||||
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
|
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
@@ -1128,6 +1196,23 @@ function attachStep5(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Completion handoff ---
|
||||||
|
|
||||||
|
/// Open the fullscreen vault tab and best-effort close the setup tab.
|
||||||
|
export async function finishSetup(): Promise<void> {
|
||||||
|
const vaultUrl = chrome.runtime.getURL('vault.html');
|
||||||
|
await chrome.tabs.create({ url: vaultUrl });
|
||||||
|
try {
|
||||||
|
const current = await chrome.tabs.getCurrent();
|
||||||
|
if (current?.id !== undefined) {
|
||||||
|
await chrome.tabs.remove(current.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
|
||||||
|
// The vault tab is open — that's the user-visible success.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Boot ---
|
// --- Boot ---
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
76
extension/src/shared/__tests__/color-scheme.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme,
|
||||||
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
|
} from '../color-scheme';
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: any = {}) {
|
||||||
|
const store = { ...initial };
|
||||||
|
(global as any).chrome = {
|
||||||
|
storage: {
|
||||||
|
sync: {
|
||||||
|
get: vi.fn((key: string) => Promise.resolve(
|
||||||
|
key in store ? { [key]: store[key] } : {})),
|
||||||
|
set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }),
|
||||||
|
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('color-scheme storage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// happy-dom provides document globally; reset inline styles between tests
|
||||||
|
document.documentElement.removeAttribute('style');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns defaults when storage is empty', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns stored values when present', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' },
|
||||||
|
});
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe('#123456');
|
||||||
|
expect(scheme.symbol_color).toBe('#abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save round-trips', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset removes the storage key', async () => {
|
||||||
|
const store = mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' },
|
||||||
|
});
|
||||||
|
await resetColorScheme();
|
||||||
|
expect(store.password_display_scheme).toBeUndefined();
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('apply sets CSS custom properties on document.documentElement', async () => {
|
||||||
|
mockChromeStorage({
|
||||||
|
password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' },
|
||||||
|
});
|
||||||
|
await applyColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe');
|
||||||
|
expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save rejects malformed hex values', async () => {
|
||||||
|
mockChromeStorage();
|
||||||
|
await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' }))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
extension/src/shared/__tests__/error-copy.test.ts
Normal file
44
extension/src/shared/__tests__/error-copy.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { ERROR_COPY, lookupErrorCopy } from '../error-copy';
|
||||||
|
|
||||||
|
const repoRoot = resolve(__dirname, '../../../..');
|
||||||
|
|
||||||
|
function discoverCodes(): Set<string> {
|
||||||
|
const out = execSync(
|
||||||
|
`grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \
|
||||||
|
--include="*.ts" --exclude-dir=__tests__`,
|
||||||
|
{ cwd: repoRoot, encoding: 'utf-8' },
|
||||||
|
);
|
||||||
|
const codes = new Set<string>();
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const m = line.match(/error: '([^']+)'/);
|
||||||
|
if (m) codes.add(m[1]);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ERROR_COPY', () => {
|
||||||
|
it('contains an entry for every error code returned by the service worker', () => {
|
||||||
|
const discovered = discoverCodes();
|
||||||
|
expect(discovered.size).toBeGreaterThan(0);
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const code of discovered) {
|
||||||
|
if (!ERROR_COPY[code]) missing.push(code);
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookupErrorCopy returns the mapped entry for known codes', () => {
|
||||||
|
const copy = lookupErrorCopy('vault_locked');
|
||||||
|
expect(copy.title).toBe('Vault locked');
|
||||||
|
expect(copy.body).toMatch(/unlock/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lookupErrorCopy falls back to a generic shape for unknown codes', () => {
|
||||||
|
const copy = lookupErrorCopy('made_up_code_xyz');
|
||||||
|
expect(copy.title).toBe('Something went wrong');
|
||||||
|
expect(copy.body).toContain('made_up_code_xyz');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import * as glyphs from '../glyphs';
|
import * as glyphs from '../glyphs';
|
||||||
|
import {
|
||||||
|
GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB,
|
||||||
|
GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS,
|
||||||
|
GLYPH_LOCK, GLYPH_NEXT,
|
||||||
|
} from '../glyphs';
|
||||||
|
|
||||||
describe('glyphs', () => {
|
describe('glyphs', () => {
|
||||||
it('exports the documented glyph constants', () => {
|
it('exports the documented glyph constants', () => {
|
||||||
@@ -19,3 +24,20 @@ describe('glyphs', () => {
|
|||||||
expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>');
|
expect(glyphs.REQUIRED_PILL_HTML).toBe('<span class="req-pill">required</span>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('glyph constants', () => {
|
||||||
|
it('uses single unicode codepoints (no emoji multi-codepoint)', () => {
|
||||||
|
const all = [
|
||||||
|
GLYPH_REVEAL, GLYPH_HIDE, GLYPH_GENERATE, GLYPH_FILL_FROM_TAB,
|
||||||
|
GLYPH_QR, GLYPH_MONO, GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS,
|
||||||
|
GLYPH_LOCK, GLYPH_NEXT,
|
||||||
|
];
|
||||||
|
for (const g of all) {
|
||||||
|
expect([...g].length).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GLYPH_NEXT is the small right triangle (U+25B8)', () => {
|
||||||
|
expect(GLYPH_NEXT).toBe('▸');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
60
extension/src/shared/__tests__/password-coloring.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring';
|
||||||
|
|
||||||
|
describe('colorizePassword', () => {
|
||||||
|
|
||||||
|
function classes(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.className);
|
||||||
|
}
|
||||||
|
function texts(frag: DocumentFragment): string[] {
|
||||||
|
return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns empty fragment for empty input', () => {
|
||||||
|
const frag = colorizePassword('');
|
||||||
|
expect(frag.childNodes.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a mixed-class run', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-letters produces a single letter span', () => {
|
||||||
|
const frag = colorizePassword('passwd');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['passwd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-digits produces a single digit span', () => {
|
||||||
|
const frag = colorizePassword('123456');
|
||||||
|
expect(classes(frag)).toEqual([PWD_DIGIT]);
|
||||||
|
expect(texts(frag)).toEqual(['123456']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all-symbols produces a single symbol span', () => {
|
||||||
|
const frag = colorizePassword('!@#$%^');
|
||||||
|
expect(classes(frag)).toEqual([PWD_SYMBOL]);
|
||||||
|
expect(texts(frag)).toEqual(['!@#$%^']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies unicode letters as letters', () => {
|
||||||
|
const frag = colorizePassword('áñü');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies whitespace as symbol', () => {
|
||||||
|
const frag = colorizePassword('a b');
|
||||||
|
expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]);
|
||||||
|
expect(texts(frag)).toEqual(['a', ' ', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('representative password snapshot: aB3$xY7&_!', () => {
|
||||||
|
const frag = colorizePassword('aB3$xY7&_!');
|
||||||
|
expect(classes(frag)).toEqual([
|
||||||
|
PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL,
|
||||||
|
]);
|
||||||
|
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
extension/src/shared/color-scheme.ts
Normal file
48
extension/src/shared/color-scheme.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export const DEFAULT_DIGIT_COLOR = '#2563eb';
|
||||||
|
export const DEFAULT_SYMBOL_COLOR = '#dc2626';
|
||||||
|
const STORAGE_KEY = 'password_display_scheme';
|
||||||
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
export interface ColorScheme {
|
||||||
|
digit_color: string;
|
||||||
|
symbol_color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SCHEME: ColorScheme = {
|
||||||
|
digit_color: DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValid(s: ColorScheme): boolean {
|
||||||
|
return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadColorScheme(): Promise<ColorScheme> {
|
||||||
|
const result = await chrome.storage.sync.get(STORAGE_KEY);
|
||||||
|
const stored = result[STORAGE_KEY] as Partial<ColorScheme> | undefined;
|
||||||
|
if (!stored) return { ...DEFAULT_SCHEME };
|
||||||
|
return {
|
||||||
|
digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color)
|
||||||
|
? stored.digit_color : DEFAULT_DIGIT_COLOR,
|
||||||
|
symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color)
|
||||||
|
? stored.symbol_color : DEFAULT_SYMBOL_COLOR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveColorScheme(scheme: ColorScheme): Promise<void> {
|
||||||
|
if (!isValid(scheme)) {
|
||||||
|
throw new Error('Invalid color values; expected #rrggbb hex strings.');
|
||||||
|
}
|
||||||
|
await chrome.storage.sync.set({ [STORAGE_KEY]: scheme });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetColorScheme(): Promise<void> {
|
||||||
|
await chrome.storage.sync.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyColorScheme(): Promise<void> {
|
||||||
|
const scheme = await loadColorScheme();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
root.setProperty('--relicario-pwd-digit-color', scheme.digit_color);
|
||||||
|
root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color);
|
||||||
|
}
|
||||||
102
extension/src/shared/error-copy.ts
Normal file
102
extension/src/shared/error-copy.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export interface ErrorCta {
|
||||||
|
label: string;
|
||||||
|
action?: 'unlock' | 'reload_extension' | 'open_setup';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorCopy {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
cta?: ErrorCta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNLOCK_CTA: ErrorCta = { label: 'Unlock vault', action: 'unlock' };
|
||||||
|
|
||||||
|
export const ERROR_COPY: Record<string, ErrorCopy> = {
|
||||||
|
vault_locked: {
|
||||||
|
title: 'Vault locked',
|
||||||
|
body: 'Unlock your vault to continue.',
|
||||||
|
cta: UNLOCK_CTA,
|
||||||
|
},
|
||||||
|
unauthorized_sender: {
|
||||||
|
title: 'Action not allowed',
|
||||||
|
body: 'This action is not allowed from here.',
|
||||||
|
},
|
||||||
|
unknown_message_type: {
|
||||||
|
title: 'Internal error',
|
||||||
|
body: 'The extension received an unknown request — try reloading.',
|
||||||
|
cta: { label: 'Reload extension', action: 'reload_extension' },
|
||||||
|
},
|
||||||
|
origin_mismatch: {
|
||||||
|
title: 'Wrong site',
|
||||||
|
body: 'This login belongs to a different site — refusing to leak credentials cross-origin.',
|
||||||
|
},
|
||||||
|
not_a_login: {
|
||||||
|
title: 'Not a login',
|
||||||
|
body: 'That item does not have a username and password to fill.',
|
||||||
|
},
|
||||||
|
no_totp: {
|
||||||
|
title: 'No 2FA on this item',
|
||||||
|
body: 'This item does not have a TOTP secret configured.',
|
||||||
|
},
|
||||||
|
invalid_sender_url: {
|
||||||
|
title: 'Cannot read tab URL',
|
||||||
|
body: 'The current tab has no recognizable URL — try reloading the page.',
|
||||||
|
},
|
||||||
|
tab_navigated: {
|
||||||
|
title: 'Tab changed',
|
||||||
|
body: 'The browser tab changed before the action could complete — try again.',
|
||||||
|
},
|
||||||
|
captured_tab_gone: {
|
||||||
|
title: 'Tab is gone',
|
||||||
|
body: 'The browser tab closed before the action could complete — try again.',
|
||||||
|
},
|
||||||
|
item_not_found: {
|
||||||
|
title: 'Item not found',
|
||||||
|
body: 'That item is no longer in the vault — it may have been deleted from another device.',
|
||||||
|
},
|
||||||
|
attachment_not_found: {
|
||||||
|
title: 'Attachment missing',
|
||||||
|
body: 'The attachment is referenced in the item but is not present in the vault.',
|
||||||
|
},
|
||||||
|
upload_failed: {
|
||||||
|
title: 'Upload failed',
|
||||||
|
body: 'Could not upload the attachment — check your connection and try again.',
|
||||||
|
},
|
||||||
|
download_failed: {
|
||||||
|
title: 'Download failed',
|
||||||
|
body: 'Could not download the attachment — check your connection and try again.',
|
||||||
|
},
|
||||||
|
'invalid base32 secret': {
|
||||||
|
title: 'Invalid secret',
|
||||||
|
body: 'The TOTP secret must be valid Base32 (letters A-Z and digits 2-7 only).',
|
||||||
|
},
|
||||||
|
'no items to import': {
|
||||||
|
title: 'Nothing to import',
|
||||||
|
body: 'The CSV did not contain any importable items.',
|
||||||
|
},
|
||||||
|
'no reference image stored locally': {
|
||||||
|
title: 'No reference image',
|
||||||
|
body: 'This device has no reference image saved locally — re-attach the device or restore from backup.',
|
||||||
|
},
|
||||||
|
'remote already contains a Relicario vault': {
|
||||||
|
title: 'Vault already exists',
|
||||||
|
body: 'The selected repository already contains a vault — use Attach existing instead of Create new.',
|
||||||
|
},
|
||||||
|
'Extension not configured. Run setup first.': {
|
||||||
|
title: 'Extension not configured',
|
||||||
|
body: 'Run setup before using this action.',
|
||||||
|
cta: { label: 'Open setup', action: 'open_setup' },
|
||||||
|
},
|
||||||
|
'Reference image not set. Run setup first.': {
|
||||||
|
title: 'Reference image missing',
|
||||||
|
body: 'Run setup to save your reference image.',
|
||||||
|
cta: { label: 'Open setup', action: 'open_setup' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function lookupErrorCopy(code: string): ErrorCopy {
|
||||||
|
return ERROR_COPY[code] ?? {
|
||||||
|
title: 'Something went wrong',
|
||||||
|
body: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const GLYPH_TRASH = '▦'; // sidebar trash nav
|
|||||||
export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
|
export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
|
||||||
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
|
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
|
||||||
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
export const GLYPH_LOCK = '⏻'; // sidebar lock nav
|
||||||
|
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
|
||||||
|
|
||||||
/// Inline HTML snippet for the required-field pill. Use after a label's text:
|
/// Inline HTML snippet for the required-field pill. Use after a label's text:
|
||||||
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
|
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type PopupMessage =
|
|||||||
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
||||||
| { type: 'download_attachment'; itemId: string; attachmentId: string }
|
| { type: 'download_attachment'; itemId: string; attachmentId: string }
|
||||||
| { type: 'list_devices' }
|
| { type: 'list_devices' }
|
||||||
|
| { type: 'list_revoked' }
|
||||||
| { type: 'add_device'; name: string; public_key: string }
|
| { type: 'add_device'; name: string; public_key: string }
|
||||||
| { type: 'register_this_device'; name: string }
|
| { type: 'register_this_device'; name: string }
|
||||||
| { type: 'revoke_device'; name: string }
|
| { type: 'revoke_device'; name: string }
|
||||||
@@ -60,7 +61,9 @@ export type PopupMessage =
|
|||||||
}
|
}
|
||||||
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
||||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||||
| { type: 'preview_totp_from_secret'; secret_b32: string };
|
| { type: 'preview_totp_from_secret'; secret_b32: string }
|
||||||
|
| { type: 'generate_recovery_qr'; passphrase: string }
|
||||||
|
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
|
||||||
|
|
||||||
// --- Messages a content script may send ---
|
// --- Messages a content script may send ---
|
||||||
|
|
||||||
@@ -139,6 +142,10 @@ export interface ListDevicesResponse extends Extract<Response, { ok: true }> {
|
|||||||
data: { devices: Device[] };
|
data: { devices: Device[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListRevokedResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { revoked: Array<{ name: string; public_key: string; revoked_at: number; revoked_by: string }> };
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
|
export interface ListTrashedResponse extends Extract<Response, { ok: true }> {
|
||||||
data: { items: Array<[ItemId, ManifestEntry]> };
|
data: { items: Array<[ItemId, ManifestEntry]> };
|
||||||
}
|
}
|
||||||
@@ -161,13 +168,14 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
|||||||
'ack_autofill_origin', 'get_settings', 'update_settings',
|
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||||
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||||
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
|
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
|
||||||
'list_devices', 'add_device', 'register_this_device', 'revoke_device',
|
'list_devices', 'list_revoked', 'add_device', 'register_this_device', 'revoke_device',
|
||||||
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
||||||
'get_field_history',
|
'get_field_history',
|
||||||
'get_session_config', 'update_session_config',
|
'get_session_config', 'update_session_config',
|
||||||
'export_backup', 'restore_backup',
|
'export_backup', 'restore_backup',
|
||||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||||
'preview_totp_from_secret',
|
'preview_totp_from_secret',
|
||||||
|
'generate_recovery_qr', 'unwrap_recovery_qr',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||||
|
|||||||
35
extension/src/shared/password-coloring.ts
Normal file
35
extension/src/shared/password-coloring.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export const PWD_DIGIT = 'pwd-digit';
|
||||||
|
export const PWD_SYMBOL = 'pwd-symbol';
|
||||||
|
export const PWD_LETTER = 'pwd-letter';
|
||||||
|
|
||||||
|
type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER;
|
||||||
|
|
||||||
|
function classify(ch: string): Class {
|
||||||
|
if (/^\d$/.test(ch)) return PWD_DIGIT;
|
||||||
|
if (/^\p{L}$/u.test(ch)) return PWD_LETTER;
|
||||||
|
return PWD_SYMBOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorizePassword(text: string): DocumentFragment {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
if (text.length === 0) return frag;
|
||||||
|
|
||||||
|
const codepoints = Array.from(text);
|
||||||
|
let runStart = 0;
|
||||||
|
let runClass = classify(codepoints[0]);
|
||||||
|
|
||||||
|
for (let i = 1; i <= codepoints.length; i++) {
|
||||||
|
const c = i < codepoints.length ? classify(codepoints[i]) : null;
|
||||||
|
if (c !== runClass) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = runClass;
|
||||||
|
span.textContent = codepoints.slice(runStart, i).join('');
|
||||||
|
frag.appendChild(span);
|
||||||
|
if (c !== null) {
|
||||||
|
runStart = i;
|
||||||
|
runClass = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user