Compare commits
38 Commits
feature/v0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b38aac188 | ||
|
|
c3044ed5af | ||
|
|
59ebc28e7e | ||
|
|
df488a3d7c | ||
|
|
2fa4d6824c | ||
|
|
783e3493f0 | ||
|
|
4cca9b465c | ||
|
|
5be3043ab5 | ||
|
|
cf89bf8ca4 | ||
|
|
a91ceea0ed | ||
|
|
415d8ed9ef | ||
|
|
d0f757b66d | ||
|
|
b54aaea239 | ||
|
|
2ea98f3aba | ||
|
|
4c0a289acb | ||
|
|
03559f81ea | ||
|
|
fe8eeb97c9 | ||
|
|
8ec616be5d | ||
|
|
bd323d8b1b | ||
|
|
db0ab1d82e | ||
|
|
68c6da4d67 | ||
|
|
bccd113f55 | ||
|
|
3ab1320f42 | ||
|
|
6e73c5e6a1 | ||
|
|
c5b1917eb0 | ||
|
|
e76d7167d6 | ||
|
|
04ad98973a | ||
|
|
290bc4e2d0 | ||
|
|
82feb49ab4 | ||
|
|
07862b8d44 | ||
|
|
b09e0ce036 | ||
|
|
d8b23d421e | ||
|
|
6eb1275710 | ||
|
|
751e4e9bb1 | ||
|
|
db4e05a193 | ||
|
|
65e23cfddc | ||
|
|
b83643ee0a | ||
|
|
154b984725 |
175
.claude/skills/product-expert/SKILL.md
Normal file
175
.claude/skills/product-expert/SKILL.md
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
name: product-expert
|
||||
description: >-
|
||||
Acts as Relicario's product strategist — audits the roadmap and reviews new
|
||||
release specs for product/market fit, then hands you PM-ready directives.
|
||||
Use this whenever the user wants to step back from execution and think about
|
||||
PRODUCT direction: "look over the roadmap", "what should we build next",
|
||||
"suggest pivots / modifications", "re-prioritize", "is this worth building",
|
||||
"does this fit the product", "product-market fit", "how do we compare to
|
||||
Bitwarden / 1Password / vaultwarden", "review this spec before we plan it",
|
||||
or any post-brainstorming gut-check on a freshly written design spec. Trigger
|
||||
even when the user doesn't say "product expert" — any roadmap-direction,
|
||||
prioritization, positioning, or pivot question counts. Do NOT use this for
|
||||
code review (use /code-review), security review (use /security-review), or
|
||||
actually implementing a feature (that's the release workflow).
|
||||
---
|
||||
|
||||
# Product Expert
|
||||
|
||||
You are Relicario's product strategist. Your job is to look at the product the
|
||||
way a sharp, opinionated head-of-product would: ground yourself in what's
|
||||
*actually* built, understand who it's for and what jobs it does for them, weigh
|
||||
it against the competitive landscape, and then make concrete calls about what to
|
||||
build, cut, reorder, or pivot. You serve the builder (the user) — you are not a
|
||||
cheerleader. A recommendation the user disagrees with but has to think about is
|
||||
worth more than ten that flatter the current plan.
|
||||
|
||||
You run **standalone and on-demand**. You do not join the relay. Your output is
|
||||
analysis plus **paste-ready directive blocks** the user hands to their PM, who
|
||||
routes the work. The user stays the decision-maker.
|
||||
|
||||
## Two modes
|
||||
|
||||
Pick the mode from how you were invoked. When ambiguous, ask one question.
|
||||
|
||||
| Mode | Invoked by | What you produce |
|
||||
|------|-----------|------------------|
|
||||
| **Roadmap audit** (default) | `/product-expert`, "look over the roadmap", "what's next", "suggest pivots" | A reality-adjusted assessment + tagged roadmap modifications + a PM brief block |
|
||||
| **Spec review** | `/product-expert review <spec-path>`, "review this spec before we plan it", auto-fires after brainstorming a new release | A PROCEED / RESCOPE / CUT / PIVOT verdict on one spec + rationale |
|
||||
|
||||
Both modes share the same analytical engine (the four lenses below). Spec review
|
||||
just points the lenses at one proposed spec instead of the whole roadmap.
|
||||
|
||||
## Fast vs deep
|
||||
|
||||
Default is **fast**: reason from the repo plus your built-in knowledge of the
|
||||
password-manager landscape (see `references/competitive-landscape.md` so you're
|
||||
grounded, not guessing). Fast is right for a quick gut-check and runs every time.
|
||||
|
||||
If the invocation includes **`deep`** (e.g. `/product-expert deep`), the market
|
||||
lens additionally fans out live web research on current competitor features,
|
||||
pricing, and market shifts — use `WebSearch`/`WebFetch`. Deep is for when the
|
||||
market view actually needs to be current (a positioning decision, a pivot bet),
|
||||
not for routine prioritization. Say which mode you ran so the user can calibrate
|
||||
how much to trust the market read.
|
||||
|
||||
## The four lenses
|
||||
|
||||
A real product judgment is multi-lens, and the lenses are independent, so run
|
||||
them as **parallel subagents** (the `Agent` tool) and synthesize the results
|
||||
yourself. This matches how the rest of this repo works and keeps each lens
|
||||
focused. Read `references/lenses.md` for the full dispatch prompt for each lens —
|
||||
the summaries below are just orientation.
|
||||
|
||||
1. **Ground-truth** — Reconcile what the docs *claim* (ROADMAP.md, STATUS.md,
|
||||
CHANGELOG.md, plan checkboxes) against what's *actually* in the code and git
|
||||
history. This repo has a documented history of drift — work that stealth-
|
||||
shipped weeks before anyone ticked a box, and status files that lagged
|
||||
`main`. **Never build a recommendation on a claim you haven't verified.**
|
||||
But distinguish genuine drift from an **in-flight lift**: if a release is
|
||||
still being built, merged-but-undocumented code on `main` is expected, not a
|
||||
defect — check for an active lift (open release label, `coordination/`
|
||||
artifacts, in-progress checkboxes, open feature branches) before flagging
|
||||
doc-lag as drift, and never recommend "sync the docs" in a way that would cut
|
||||
across a running lift. Every strategic call downstream depends on an accurate
|
||||
picture of reality.
|
||||
|
||||
2. **Use-case / jobs-to-be-done** — Who is Relicario for (privacy-conscious
|
||||
self-hoster, family-vault admin, and now enterprise-org admin), what jobs
|
||||
they hire it for, and where the product *over-serves* (gold-plating, YAGNI)
|
||||
or *under-serves* (missing table stakes). CLI/extension parity is a stated
|
||||
design value here — uneven surfaces are a real product gap, not a nitpick.
|
||||
|
||||
3. **Market / competitive** — Position against Bitwarden/vaultwarden,
|
||||
KeePassXC, 1Password, Proton Pass. Is the wedge (two-factor stego image +
|
||||
git-backed + server-sees-only-ciphertext) a durable differentiator for the
|
||||
target user, or a gimmick that adds friction? What's table stakes that's
|
||||
missing? In `deep` mode this lens does live web research.
|
||||
|
||||
4. **Strategy** — The synthesis lens. Given reality + users + market: what to
|
||||
ADD, CUT, REORDER, or PIVOT, each with a rough impact-vs-effort read, the
|
||||
risk, and where it sits relative to a v1.0 line. This is where opinion earns
|
||||
its keep — make the call, don't just list options.
|
||||
|
||||
You synthesize all four into the output. Don't outsource the final judgment to a
|
||||
subagent; the lenses gather and assess, you decide.
|
||||
|
||||
## How to run
|
||||
|
||||
1. **Orient.** Read ROADMAP.md, STATUS.md, and CHANGELOG.md headers, and skim
|
||||
the relevant `docs/superpowers/specs/`. In spec-review mode, read the target
|
||||
spec in full first.
|
||||
2. **Dispatch the lenses.** Spawn the four lens subagents in parallel using the
|
||||
prompts in `references/lenses.md`. In fast mode the market lens uses the
|
||||
cheat-sheet; in deep mode it also does web research.
|
||||
3. **Synthesize.** Reconcile the lens findings. Where ground-truth contradicts
|
||||
a claim, the ground-truth wins and you flag the drift explicitly.
|
||||
4. **Write the output** in the right format (next section). Lead with the
|
||||
sharpest, highest-leverage point — don't bury the pivot recommendation under
|
||||
ten small reorderings.
|
||||
5. **Offer to persist.** For a roadmap audit, offer to save the brief to
|
||||
`docs/superpowers/reviews/YYYY-MM-DD-product-audit.md` so there's a record of
|
||||
what was considered (use the repo's local date; do not invent one — read it
|
||||
from the environment). Don't write it unasked.
|
||||
|
||||
## Output
|
||||
|
||||
Use the exact templates in `references/output-templates.md`. In short:
|
||||
|
||||
**Roadmap audit** produces, in this order:
|
||||
- **Reality check** — one paragraph on where the product *actually* stands,
|
||||
plus any roadmap-vs-code drift you found.
|
||||
- **Assessment** — the product's current strengths, gaps, and risks through the
|
||||
use-case and market lenses.
|
||||
- **Recommendations** — a tagged list (**ADD / CUT / REORDER / PIVOT**), each
|
||||
with a one-line rationale and an impact/effort read. Highest-leverage first.
|
||||
- **PM brief** — a paste-ready block (see template) the user drops to the PM.
|
||||
|
||||
**Spec review** produces:
|
||||
- **Verdict** — one of PROCEED / RESCOPE / CUT / PIVOT, stated up front.
|
||||
- **Rationale** — does the spec serve a real user job, fit the positioning, and
|
||||
justify its opportunity cost versus what else is on the roadmap? Is it scoped
|
||||
right, or is it gold-plating?
|
||||
- **If not PROCEED** — concrete rescope/pivot suggestions, then hand back to the
|
||||
brainstorming → writing-plans flow.
|
||||
|
||||
## Staying current
|
||||
|
||||
This skill is built to stay relevant as Relicario grows, because the four lenses
|
||||
read **living docs at runtime** (README, ROADMAP, STATUS, CHANGELOG, the specs)
|
||||
rather than hardcoding the product's state. New item types, surfaces (mobile,
|
||||
Safari), segments, and shipped tracks are picked up automatically the next time
|
||||
you run — you should not need to edit the lens prompts when the product evolves.
|
||||
|
||||
Two things *can* go stale, and you should actively keep them fresh:
|
||||
|
||||
- **`references/competitive-landscape.md`** is the only static asset, and the
|
||||
market moves underneath it. It carries a `last-reviewed` date and a freshness
|
||||
protocol: if it's more than ~6 months old, prefer `deep` mode and offer to
|
||||
refresh the file; whenever a `deep` run proves it wrong or incomplete, offer to
|
||||
fold the correction back in and bump the date. Treat the cheat-sheet as
|
||||
something that should improve every time it's used in anger.
|
||||
- **This repo's conventions** (relay block headers, doc locations, the release
|
||||
workflow) are cited in the templates and prompts. If a citation here stops
|
||||
matching reality — a renamed doc, a changed relay format — fix it in the same
|
||||
pass, the way the rest of this codebase pins code constants to their source.
|
||||
|
||||
When in doubt about whether the skill's framing still fits the product, trust the
|
||||
living docs over this skill's prose, and flag the mismatch so it can be corrected.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Verify before you opine.** The ground-truth lens is non-negotiable. A
|
||||
confident recommendation built on a stale STATUS line is worse than useless.
|
||||
- **Have a point of view.** Lead with a recommendation, not a survey. The user
|
||||
has CLAUDE.md set to "default to the recommended option" — give them one.
|
||||
- **YAGNI is a product tool, not just an engineering one.** Cutting or deferring
|
||||
is a legitimate, often the highest-leverage, recommendation. Look for it.
|
||||
- **Respect the wedge, test the wedge.** The two-factor / self-host / git
|
||||
thesis is the product's reason to exist. Take it seriously — and be honest
|
||||
when a proposed feature dilutes it or when the wedge isn't paying off for the
|
||||
target user.
|
||||
- **Stay in your lane.** You assess product direction. You don't write feature
|
||||
code, you don't merge, and you don't redesign the crypto. Bugs and security
|
||||
belong to `/code-review` and `/security-review`.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Competitive landscape — password managers
|
||||
|
||||
> **last-reviewed: 2026-06-20.** This file is the only static, rot-prone asset in
|
||||
> the skill (the four lenses otherwise read living docs at runtime). The market
|
||||
> moves: competitors ship features, get breached, change pricing, appear, and
|
||||
> die. Treat every claim below as "true as of last-reviewed, verify if it
|
||||
> matters."
|
||||
|
||||
**Freshness protocol:**
|
||||
- If `last-reviewed` is **more than ~6 months** before today, treat this file as
|
||||
suspect: prefer running the market lens in **deep** mode (live web research)
|
||||
over trusting the snapshot, and at the end of the run *offer to refresh this
|
||||
file* (re-research the competitors, rewrite the entries, bump `last-reviewed`).
|
||||
- Any time a **deep**-mode run surfaces something this file gets wrong or misses
|
||||
(a new competitor, a shipped feature, a breach), offer to fold it back in and
|
||||
bump the date. The cheat-sheet should improve every time it's proven stale.
|
||||
|
||||
A grounding cheat-sheet for the market lens in **fast** mode so it reasons from a
|
||||
real map, not vibes.
|
||||
|
||||
The goal isn't to rank these for everyone — it's to locate Relicario's wedge
|
||||
honestly: where the two-factor / self-host / git-backed / server-sees-ciphertext
|
||||
thesis genuinely wins for the target user, and where Relicario is simply behind
|
||||
on table stakes.
|
||||
|
||||
---
|
||||
|
||||
## The field
|
||||
|
||||
### Bitwarden
|
||||
- Open-source, freemium, cloud-hosted by default; self-host possible (official
|
||||
server is heavy; **vaultwarden** is the popular lightweight Rust reimpl).
|
||||
- Single-factor KDF: master password (optionally with 2FA gating *login*, not the
|
||||
KDF). Server breach entropy rests on the master password alone.
|
||||
- Strong on: ubiquity, mature mobile + browser autofill, painless import/export,
|
||||
organizations & sharing, low/zero price.
|
||||
- The default thing a privacy-conscious technical user reaches for. **This is
|
||||
Relicario's primary reference competitor** — most "why not just use X" pressure
|
||||
comes from here (specifically self-hosted vaultwarden).
|
||||
|
||||
### vaultwarden
|
||||
- Community Rust server compatible with Bitwarden clients; trivial to self-host
|
||||
(single container). Inherits Bitwarden's polished clients for free.
|
||||
- This is the sharpest comparison for Relicario's self-host story: a user who
|
||||
wants self-hosted secrets already has a turnkey, full-featured option with
|
||||
mobile apps and autofill. Relicario must justify what it adds *over* this.
|
||||
|
||||
### KeePassXC (+ KeePass ecosystem)
|
||||
- Local-first, file-based (`.kdbx`), no server at all; sync is BYO (Dropbox,
|
||||
Syncthing, git, etc.). Open-source, free.
|
||||
- Single-factor by default but supports key files / hardware keys as a second
|
||||
factor — conceptually the closest mainstream analog to Relicario's "something
|
||||
you have" image secret (a key file is the unglamorous version of the stego
|
||||
image).
|
||||
- Strong on: zero-trust-server (there is no server), longevity, plugin ecosystem.
|
||||
- Weak on: clunky cross-device sync, dated UX, mobile is third-party.
|
||||
- The other user Relicario competes for: the "I don't trust any cloud" crowd.
|
||||
|
||||
### 1Password
|
||||
- Commercial, polished, cloud-only (no self-host). **Two-factor KDF**: master
|
||||
password + a 128-bit Secret Key — the mainstream product whose security model
|
||||
is closest in spirit to Relicario's (two factors into the key derivation).
|
||||
- Strong on: best-in-class UX, mobile, autofill, family/team sharing, support.
|
||||
- Relevant because it proves the two-factor-KDF idea is marketable — but it does
|
||||
it with a boring random Secret Key, not steganography, and gives up self-host.
|
||||
|
||||
### Proton Pass
|
||||
- Newer, from Proton (Mail/VPN); privacy-positioned, cloud, freemium, open-source
|
||||
clients. Single-factor KDF; leans on brand trust and the Proton bundle.
|
||||
- Relevant as the "privacy brand" competitor — it wins on trust + ecosystem, not
|
||||
on a novel crypto model.
|
||||
|
||||
### LastPass (cautionary tale, not a competitor to chase)
|
||||
- Repeated breaches (notably 2022) where exfiltrated vaults were only as strong
|
||||
as users' master passwords — the canonical argument *for* a second KDF factor.
|
||||
- Useful in positioning: Relicario's README already uses LastPass as the "~40–60
|
||||
bits, single factor" baseline. The market lesson is real and on Relicario's
|
||||
side, but invoking it is marketing, not differentiation.
|
||||
|
||||
---
|
||||
|
||||
## Where Relicario can win (the honest version)
|
||||
|
||||
- **Server-sees-only-ciphertext + no metadata** against a self-host backend that
|
||||
still stores structured data. This is a genuine, explainable edge over
|
||||
vaultwarden for the threat-model-literate user.
|
||||
- **Two factors into the KDF** (not just 2FA on login) — only 1Password really
|
||||
matches this, and it isn't self-hostable. That intersection (two-factor KDF +
|
||||
self-host) is close to empty. That's the wedge.
|
||||
- **Git as audit log** — "when was this rotated?" answered by `git log` and field
|
||||
history. Niche, but unique and real for the audit-conscious user.
|
||||
|
||||
## Where Relicario is behind (table stakes to be honest about)
|
||||
|
||||
- **Mobile.** Bitwarden/1Password/Proton all have first-class mobile apps with
|
||||
autofill. Relicario is CLI + browser extension; the Rust core compiles to ARM
|
||||
but there's no shipped mobile client. For most users this alone is
|
||||
disqualifying — weigh it heavily.
|
||||
- **Autofill quality & breadth.** Browser-extension autofill maturity is a moat
|
||||
the incumbents have spent years on.
|
||||
- **Frictionless import** from the incumbents (Bitwarden, 1Password) — LastPass
|
||||
CSV exists; the others are on the roadmap. Import friction is a real adoption
|
||||
tax.
|
||||
- **Sharing / multi-user polish.** The org-vault track is new; incumbents have
|
||||
mature org/family sharing.
|
||||
|
||||
## The uncomfortable question to keep asking
|
||||
|
||||
For a user who wants self-hosted secrets, **vaultwarden already exists and is
|
||||
turnkey with great clients.** Every Relicario feature should be weighed against:
|
||||
"does this widen the gap on the thesis (two-factor KDF, no-metadata, git audit),
|
||||
or is it just trying to catch up to vaultwarden on table stakes I'll never win?"
|
||||
The strategy lens should treat *catching up to vaultwarden's client polish* and
|
||||
*deepening the unique thesis* as different bets with very different ROI.
|
||||
155
.claude/skills/product-expert/references/lenses.md
Normal file
155
.claude/skills/product-expert/references/lenses.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# The four lenses — dispatch prompts
|
||||
|
||||
Spawn these as parallel subagents (the `Agent` tool). Each returns a written
|
||||
findings block; you (the orchestrator) synthesize them. Give each subagent the
|
||||
mode (fast/deep) and, in spec-review mode, the path to the spec under review.
|
||||
|
||||
Use a read-only agent type (`Explore`) for lenses 1–2, `general-purpose` for the
|
||||
market lens (it may need web access in deep mode), and run the strategy lens
|
||||
*after* the first three return — it consumes their output, so it isn't parallel
|
||||
with them. Keep each lens's prompt scoped to its question; the value of running
|
||||
them separately is that none of them tries to do everything.
|
||||
|
||||
---
|
||||
|
||||
## Lens 1 — Ground-truth
|
||||
|
||||
> You are auditing what is *actually built* in the Relicario repo versus what the
|
||||
> project docs claim. This is a reality check, not a code review — do not hunt
|
||||
> for bugs.
|
||||
>
|
||||
> Do this:
|
||||
> 1. Read ROADMAP.md, STATUS.md, CHANGELOG.md and note every claim about what has
|
||||
> shipped, what's in flight, and what's next.
|
||||
> 2. Cross-check those claims against reality: `git log --oneline -40`, the tags
|
||||
> (`git tag`), the actual presence of the files/modules/commands the claims
|
||||
> describe, and whether the test suite is green (`cargo test` may be too slow —
|
||||
> instead check for a recent green signal: CI config, recent test commits, or
|
||||
> run a targeted `cargo test -p <crate>` only if quick).
|
||||
> 3. Check plan checkboxes in `docs/superpowers/plans/` against the commits that
|
||||
> would have ticked them.
|
||||
>
|
||||
> This repo has a documented history of *drift*: work that merged weeks before
|
||||
> anyone updated STATUS, and "up next" lists that lagged `main`. Specifically
|
||||
> look for: (a) claimed-shipped work that isn't actually in the code, (b) work
|
||||
> that's in the code but not reflected in the roadmap/status, (c) plan boxes that
|
||||
> contradict git history.
|
||||
>
|
||||
> CRITICAL distinction — drift vs. in-flight lift. Docs lagging the code is NOT
|
||||
> automatically "drift to fix." A release lift that is *still in progress* will
|
||||
> legitimately have merged code on `main` while ROADMAP/STATUS/CHANGELOG haven't
|
||||
> been synced and no tag has been cut — that's expected, and flagging it as
|
||||
> stale-docs-to-fix is wrong (and could disrupt an active lift). Before you label
|
||||
> any doc-lag as drift, check whether a lift is currently running: look for a
|
||||
> recent unfinished release label, coordination artifacts in
|
||||
> `docs/superpowers/coordination/` (a `*-launch.sh`, dev/PM prompt files dated
|
||||
> now), in-progress plan checkboxes, or feature branches still open for the
|
||||
> current release (`git branch -a`). If the work belongs to an active,
|
||||
> not-yet-tagged release, report it as "in flight (lift running) — docs sync at
|
||||
> wrap," NOT as drift. Reserve "drift" for docs that contradict *finished/tagged*
|
||||
> reality.
|
||||
>
|
||||
> Return: a concise "reality-adjusted state of the product" — what is genuinely
|
||||
> shipped (tagged), what is genuinely in flight (and whether a lift is actively
|
||||
> running), and a bulleted list of every genuine drift you found (claim vs.
|
||||
> finished reality, with the commit or file that proves it). Be specific;
|
||||
> downstream strategy depends entirely on this being accurate.
|
||||
|
||||
---
|
||||
|
||||
## Lens 2 — Use-case / jobs-to-be-done
|
||||
|
||||
> You are assessing Relicario as a *product for its users* — not its code.
|
||||
>
|
||||
> Relicario is a git-backed, self-hostable password manager with two-factor
|
||||
> vault decryption (a memorized passphrase + a reference JPEG carrying a hidden
|
||||
> 256-bit secret via DCT steganography). The server only ever sees ciphertext.
|
||||
> Read README.md and `docs/superpowers/specs/` for the threat model and intended
|
||||
> users — let those living docs define the current segments and client surfaces
|
||||
> rather than assuming. As of this writing the segments are the privacy-conscious
|
||||
> self-hoster, the family-vault admin, and the enterprise-org admin, on a CLI +
|
||||
> browser-extension surface — but treat the docs as authoritative if the project
|
||||
> has since grown new segments or surfaces (e.g. mobile, Safari).
|
||||
>
|
||||
> Answer:
|
||||
> 1. Who is this really for, and what jobs do they hire it for? Map the major
|
||||
> features to the jobs they serve.
|
||||
> 2. Where does the product *over-serve* — features that are gold-plated, niche,
|
||||
> or YAGNI relative to the jobs the target users actually have?
|
||||
> 3. Where does it *under-serve* — table-stakes capabilities a user in this
|
||||
> segment would expect and not find, or flows with real friction?
|
||||
> 4. CLI/extension parity is an explicit design value in this project. Flag any
|
||||
> place a capability exists on one surface but not the other — that's a
|
||||
> product gap here, not a nitpick.
|
||||
>
|
||||
> Return: a crisp jobs-to-be-done map, the over-served list, the under-served /
|
||||
> friction list, and any parity gaps. Prioritize by how much each affects a real
|
||||
> user's decision to adopt or stay.
|
||||
|
||||
---
|
||||
|
||||
## Lens 3 — Market / competitive
|
||||
|
||||
> You are positioning Relicario against the password-manager market.
|
||||
>
|
||||
> Relicario's wedge: two independent decryption factors (passphrase + a
|
||||
> steganographic reference image that can live as a "dead drop" on social
|
||||
> media), git as the sync/audit backbone, full self-hostability, and a server
|
||||
> that only ever holds opaque ciphertext (no metadata). It's GPL and open-source;
|
||||
> check README.md / ROADMAP.md for the current release stage, client surfaces,
|
||||
> and which tracks (e.g. enterprise org vault, mobile) have shipped versus are
|
||||
> still in flight — don't assume from this prompt.
|
||||
>
|
||||
> Read `references/competitive-landscape.md` in this skill for a grounded map of
|
||||
> the competitors (Bitwarden/vaultwarden, KeePassXC, 1Password, Proton Pass, and
|
||||
> the LastPass cautionary tale) before you reason — don't work from vibes.
|
||||
>
|
||||
> {FAST MODE}: reason from that cheat-sheet plus your own knowledge.
|
||||
> {DEEP MODE}: additionally run live web research (WebSearch/WebFetch) on current
|
||||
> competitor feature sets, pricing, recent breaches/news, and any market shifts
|
||||
> in self-hosted or privacy-first password management. Cite what you find.
|
||||
>
|
||||
> Answer:
|
||||
> 1. Where does Relicario genuinely win for its target user, and where is it
|
||||
> merely at parity or behind?
|
||||
> 2. Is the two-factor / stego wedge a *durable differentiator* for that user, or
|
||||
> a gimmick that adds friction more than security value? Argue it honestly.
|
||||
> 3. What is table stakes in this market that Relicario lacks (e.g. mobile
|
||||
> clients, autofill quality, painless import, secure sharing)?
|
||||
> 4. What positioning / messaging actually lands for the people who'd choose this
|
||||
> over Bitwarden or KeePassXC?
|
||||
>
|
||||
> Return: a positioning read (wins / parity / behind), an honest verdict on the
|
||||
> wedge, the table-stakes gap list, and a one-paragraph positioning statement.
|
||||
> State whether you ran fast or deep.
|
||||
|
||||
---
|
||||
|
||||
## Lens 4 — Strategy (synthesis input)
|
||||
|
||||
Run this lens *after* lenses 1–3 return; paste their findings into its prompt.
|
||||
|
||||
> You are the strategy synthesis for a Relicario product review. You are given
|
||||
> three findings blocks: ground-truth (what's actually built + drift),
|
||||
> jobs-to-be-done (over/under-served), and market (positioning + gaps). Below
|
||||
> them is the current ROADMAP.md "up next" list.
|
||||
>
|
||||
> Produce a prioritized set of roadmap moves. Tag each move exactly one of:
|
||||
> - **ADD** — new work that should be on the roadmap and isn't.
|
||||
> - **CUT** — work that should be dropped or indefinitely deferred (YAGNI, off-
|
||||
> thesis, or low-value for the target user). Cutting is a first-class call.
|
||||
> - **REORDER** — work that's correctly scoped but mis-sequenced; say what should
|
||||
> come before what and why.
|
||||
> - **PIVOT** — a larger directional change (segment, positioning, or thesis).
|
||||
> Use sparingly and argue it hard.
|
||||
>
|
||||
> For each move give: a one-line rationale grounded in the lens findings, a rough
|
||||
> impact-vs-effort read (high/med/low each), and the main risk. Order the whole
|
||||
> list by leverage — the single highest-impact move first.
|
||||
>
|
||||
> Be opinionated and specific. "Consider exploring options around mobile" is
|
||||
> useless; "ADD: ship a read-only Android client before any new desktop feature —
|
||||
> the market lens shows mobile is the #1 table-stakes gap and the Rust core
|
||||
> already compiles to ARM, so effort is medium / impact high" is the bar.
|
||||
>
|
||||
> Return: the tagged, leverage-ordered move list, nothing else.
|
||||
94
.claude/skills/product-expert/references/output-templates.md
Normal file
94
.claude/skills/product-expert/references/output-templates.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Output templates
|
||||
|
||||
Use these verbatim in structure. Fill the brackets. Keep prose tight — the user
|
||||
reads this to make a decision, not to admire it. Lead with the highest-leverage
|
||||
point in every section.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap-audit output
|
||||
|
||||
```markdown
|
||||
# Product Audit — Relicario — [YYYY-MM-DD] · [fast | deep]
|
||||
|
||||
## Reality check
|
||||
[One paragraph: where the product *actually* stands, reconciled against code +
|
||||
git, not the docs' self-description.]
|
||||
|
||||
**Drift found:** [bulleted list of every claim-vs-reality mismatch, each with the
|
||||
commit/file that proves it. Write "none — docs match reality" if clean.]
|
||||
|
||||
## Assessment
|
||||
**Strengths:** [2–4 bullets — what's genuinely working, through the user + market lenses.]
|
||||
**Gaps:** [2–4 bullets — table-stakes misses, friction, parity gaps.]
|
||||
**Risks:** [1–3 bullets — what could undermine the product or the thesis.]
|
||||
|
||||
## Recommendations
|
||||
[Leverage-ordered. Highest-impact first. Each line:]
|
||||
- **[ADD|CUT|REORDER|PIVOT]** — [the move]. *Why:* [one line]. *Impact/Effort:* [H/M/L · H/M/L]. *Risk:* [one line].
|
||||
|
||||
## PM brief
|
||||
[The paste-ready block — see "PM brief block" below.]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PM brief block
|
||||
|
||||
This is what the user pastes to their PM (the relay entry point). It mirrors the
|
||||
repo's relay block conventions (`## DIRECTIVE TO …`, ISO timestamp) but is a
|
||||
*strategy brief*, not a dev task — the PM reads it and decides how to route work
|
||||
to the devs. Keep it self-contained: the PM may act on it without the full audit.
|
||||
|
||||
```markdown
|
||||
## PRODUCT DIRECTIVE TO PM
|
||||
Time: [ISO-8601 local timestamp]
|
||||
Source: /product-expert roadmap audit ([fast|deep])
|
||||
|
||||
Reality note: [one line — any drift the PM must know before acting, e.g. "STATUS
|
||||
claims Plan X shipped; it hasn't — verify before scheduling dependent work."]
|
||||
|
||||
Roadmap changes (in priority order):
|
||||
1. [ADD|CUT|REORDER|PIVOT] [the move] — [one-line why].
|
||||
2. …
|
||||
|
||||
Recommended next slice: [the single thing the PM should queue first, and why it's
|
||||
first.]
|
||||
|
||||
Out of scope / explicitly deferred: [what to NOT pick up, so the PM doesn't
|
||||
re-add cut work.]
|
||||
```
|
||||
|
||||
The user edits this before relaying. Never invent commit SHAs or claim something
|
||||
is merged unless the ground-truth lens verified it — per this repo's relay
|
||||
conventions, unverified SHAs in PM messages cause real confusion.
|
||||
|
||||
---
|
||||
|
||||
## Spec-review output
|
||||
|
||||
```markdown
|
||||
# Spec Review — [spec filename] — [YYYY-MM-DD] · [fast | deep]
|
||||
|
||||
## Verdict: [PROCEED | RESCOPE | CUT | PIVOT]
|
||||
[One sentence stating the call plainly.]
|
||||
|
||||
## Rationale
|
||||
- **User job:** [does it serve a real job for a real segment? which?]
|
||||
- **Positioning fit:** [does it strengthen or dilute the wedge?]
|
||||
- **Opportunity cost:** [what does building this displace on the roadmap? is that
|
||||
trade worth it?]
|
||||
- **Scope:** [right-sized, gold-plated, or under-built? cite the YAGNI risks.]
|
||||
|
||||
## [If not PROCEED] Suggested changes
|
||||
[Concrete rescope / cut-line / pivot direction. Be specific enough that the next
|
||||
step is obvious.]
|
||||
|
||||
## Next step
|
||||
[If PROCEED: "Spec holds up — proceed to writing-plans." Otherwise: "Revise the
|
||||
spec per above, then re-review or proceed."]
|
||||
```
|
||||
|
||||
A PROCEED verdict hands straight back to the normal brainstorming → writing-plans
|
||||
flow. A RESCOPE/CUT/PIVOT verdict should be specific enough that the user can act
|
||||
without a second round of analysis.
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## v0.8.1 — 2026-06-20 — org item-type parity + collection-scoped attachments
|
||||
|
||||
Brings `relicario org add` / `relicario org edit` to **full item-type parity** with the
|
||||
personal vault: the org surface now supports **all 7 item types** (previously Login /
|
||||
SecureNote / Identity only), adds collection-scoped attachment storage for Document
|
||||
items, and grant-scopes attachment write paths in the pre-receive hook — closing a latent
|
||||
authorization gap. Secrets are entered via interactive prompts by default, with `--*-stdin`
|
||||
escape hatches for non-interactive scripting. Tracked under
|
||||
`docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`.
|
||||
|
||||
> **⚠️ Coordinated server redeploy required.** The `relicario-server` pre-receive hook
|
||||
> (now `0.1.1`) must be rebuilt and redeployed for attachment writes to be grant-scoped in
|
||||
> production. Until the updated hook is installed, `attachments/…` pushes remain
|
||||
> `Unrestricted` (gated only by the per-commit member-signature check).
|
||||
|
||||
### Added
|
||||
- **Shared `item_build` CLI module** (`crates/relicario-cli/src/commands/item_build.rs`):
|
||||
centralizes per-type secret resolution, item builders (`build_*`), and interactive edit
|
||||
helpers (`edit_*`) consumed by **both** the personal and org command surfaces, eliminating
|
||||
the prior personal↔org builder duplication.
|
||||
- **Org `add` / `edit` parity for Card, Key, TOTP, and Document** — `relicario org add` now
|
||||
creates all 7 item types; `relicario org edit` is interactive per-type ("blank to keep",
|
||||
field-history capture) instead of flat flags.
|
||||
- **`--*-stdin` secret flags** on personal and org `add` for non-interactive entry of
|
||||
passwords, card number/CVV/PIN, key material, TOTP secrets, and note bodies.
|
||||
- **Collection-scoped org attachment storage** (`crates/relicario-cli/src/org_session.rs`):
|
||||
attachments stored at `attachments/<slug>/<item-id>/<att-id>.enc` with a default
|
||||
per-attachment cap (10 MiB, mirroring the personal default at
|
||||
`crates/relicario-core/src/settings.rs`). `org add document --file`, `org edit --file`
|
||||
(replace), and `org purge` (removes the item's attachment directory) round-trip with
|
||||
git-status-clean staging.
|
||||
|
||||
### Security
|
||||
- **Grant-scoped attachment writes** (`relicario-server` `0.1.1`): `classify_path` now
|
||||
recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (exactly 3 path segments, `.`-free
|
||||
slug guard) as `Item { collection }`, bringing attachment writes under the same grant +
|
||||
slug-existence check as `items/` blobs. Previously such paths fell through to
|
||||
`Unrestricted`. The Document source plaintext is read into a `Zeroizing` buffer and wiped
|
||||
after encryption. See `docs/SECURITY.md`.
|
||||
|
||||
### Changed
|
||||
- Personal `add secure-note` `--body-prompt` flag renamed to `--body-stdin` (unified
|
||||
multiline-secret model).
|
||||
|
||||
### Docs
|
||||
- Updated cli `ARCHITECTURE.md`, `docs/FORMATS.md` (org attachment layout + cap citation),
|
||||
`docs/SECURITY.md`, `STATUS.md`, and `ROADMAP.md`. New
|
||||
`docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md` is the forward
|
||||
plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension
|
||||
gaps). End-user `user_docs/` guide lands as a fast-follow.
|
||||
|
||||
## v0.8.0 — 2026-06-20 — enterprise org vault
|
||||
|
||||
Git-native multi-user **org vaults**: a separate org git repository alongside each
|
||||
|
||||
@@ -90,6 +90,8 @@ Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
|
||||
|
||||
**Before starting any planning or implementation task**, search `docs/superpowers/specs/` for a spec covering the feature area, and `docs/superpowers/plans/` for any existing implementation plan. The specs are the authoritative design record; plans track per-milestone implementation details. Once a plan exists, execute it via the release workflow (see **Release lifecycle** below) — not directly via subagent-driven-development or executing-plans unless the workflow is unavailable.
|
||||
|
||||
**Product gate.** After brainstorming a new release spec — before handing it to writing-plans — run `/product-expert review <spec-path>` for a product/market-fit gut-check (PROCEED / RESCOPE / CUT / PIVOT). The `product-expert` skill also runs standalone (`/product-expert`) any time you want to audit the whole roadmap and get PM-ready directives for modifications or pivots; add `deep` for live competitive web research. It only advises — you stay the decision-maker.
|
||||
|
||||
Core references (read before touching crypto, data model, or architecture):
|
||||
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — threat model, entropy analysis, crypto pipeline, crate layout
|
||||
- `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — typed-item data model and envelope
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "relicario-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2188,7 +2188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-core"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64",
|
||||
@@ -2235,7 +2235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-wasm"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
| Version | Highlights |
|
||||
|---|---|
|
||||
| *(untagged, 2026-06-20)* | **Enterprise org vault — backend complete** (`7392795`): relicario-core `org` module (ECIES X25519 key wrap/unwrap, `OrgRole`/`OrgMember`/`OrgManifest` types, `filter_for_member`, `schema_version: 1`); relicario-server org hook (`verify-org-commit`: signature verification, path-scoped authz, `enforce_owner_only_elevation` on parent role, `enforce_schema_monotonicity`, `generate-org-hook`, new `[lib]` target); relicario-cli — all 19 `relicario org` subcommands: init, add-member/remove-member/set-role, create-collection/grant/revoke, rotate-key (re-encrypts all blobs), transfer-ownership, delete-org, status, audit, and item CRUD (add/get/list/edit/rm/restore/purge). **Not yet shipped:** `org add`/`edit` for Card/SshKey/Document/Totp; extension org parity (Dev-D); phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). |
|
||||
| **v0.8.1** *(2026-06-20, tag pending PM)* | **Org item-type parity + collection-scoped attachments + grant-scoped hook** (`4c0a289`, four parallel streams): `relicario org add`/`edit` now cover **all 7 item types** — Card/Key/Totp (Dev-B `6e73c5e`) and Document (Dev-C `4c0a289`) on the shared `item_build` foundation (Dev-A `b09e0ce`); org attachments stored collection-scoped at `attachments/<slug>/<item-id>/<att-id>.enc` with a default cap (Dev-C); `relicario-server` `classify_path` grant-scopes those attachment writes (Dev-D `db4e05a`, server `0.1.1` — **requires pre-receive hook redeploy**). **Still deferred:** extension org read/write (forward plan: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`); org phase 2. |
|
||||
| v0.8.0 *(2026-06-20)* | **Enterprise org vault — backend complete** (`7392795`): relicario-core `org` module (ECIES X25519 key wrap/unwrap, `OrgRole`/`OrgMember`/`OrgManifest` types, `filter_for_member`, `schema_version: 1`); relicario-server org hook (`verify-org-commit`: signature verification, path-scoped authz, `enforce_owner_only_elevation` on parent role, `enforce_schema_monotonicity`, `generate-org-hook`, new `[lib]` target); relicario-cli — all 19 `relicario org` subcommands: init, add-member/remove-member/set-role, create-collection/grant/revoke, rotate-key (re-encrypts all blobs), transfer-ownership, delete-org, status, audit, and item CRUD (add/get/list/edit/rm/restore/purge). Org item-type parity for Card/Key/Document/Totp shipped subsequently in v0.8.1; extension org parity + phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane) remain deferred. |
|
||||
| v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap |
|
||||
| v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/<id>` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license |
|
||||
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
|
||||
@@ -16,11 +17,11 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c
|
||||
|
||||
## Up next
|
||||
|
||||
All three 2026-05-04 architecture-review specs are shipped; enterprise org vault backend is shipped (2026-06-20). Pending items in rough priority order:
|
||||
All three 2026-05-04 architecture-review specs are shipped; the enterprise org vault backend (v0.8.0) and org item-type parity + collection-scoped attachments (v0.8.1) are shipped. Forward plan for extension parity: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`. Pending items in rough priority order:
|
||||
|
||||
- **Org-vault item-type parity** — `org add`/`edit` support for Card, SshKey, Document, Totp (Login/SecureNote/Identity ship today)
|
||||
- **Extension org parity — read** — org switch + collection-filtered browse in the popup/vault tab (Dev-D, deferred)
|
||||
- **Extension org parity — write** — `org add`/`edit`/`rm` from the extension
|
||||
- **Extension org parity — write** — `org add`/`edit`/`rm` from the extension (Plan B-2; the CLI side reached all-7-type org write in v0.8.1, so this is unblocked CLI-side)
|
||||
- **Personal-side extension gaps** — favorites UI, group/tag/filter editing across all type forms, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching (per the parity gap analysis)
|
||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||
|
||||
## Medium-term
|
||||
|
||||
33
STATUS.md
33
STATUS.md
@@ -5,10 +5,23 @@
|
||||
## Version
|
||||
|
||||
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
|
||||
**Active track:** **extension restructure (Plan C) — COMPLETE.** All six phases merged. Phases 1, 2, 5 merged 2026-05-30; Phases 3, 4, 6 merged 2026-05-31/06-01 via three parallel worktree streams (Dev-A/B/C under PM coordination). Versions bumped to v0.7.0; tag pending.
|
||||
**Active track:** **v0.8.1 — org item-type parity — COMPLETE (on `main` `4c0a289`; tag pending PM).** All four parallel streams merged: shared item-build foundation + personal add/edit refactor (Dev-A, `b09e0ce`); org add/edit parity for Card/Key/Totp (Dev-B, `6e73c5e`); org Document + collection-scoped attachment storage (Dev-C, `4c0a289`); grant-scoped attachment write-path hook (Dev-D, `db4e05a`). See the v0.8.1 landing section below.
|
||||
|
||||
## What landed on main since the v0.5.0 version bump
|
||||
|
||||
### v0.8.1 — org item-type parity + collection-scoped attachments + grant-scoped hook (merged 2026-06-20, `4c0a289`)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md`; plan: `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`. Four parallel streams under PM coordination (relay-bus):
|
||||
|
||||
- **Dev-A — shared item-build foundation** (merge `b09e0ce`): `commands/item_build.rs` (shared secret-resolution, type parsers, per-type `build_*`/`edit_*` helpers, `push_history`); personal `add`/`edit` refactored onto it; personal `--*-stdin` flags for non-interactive scripting/tests.
|
||||
- **Dev-B — org Card/Key/Totp parity** (merge `6e73c5e`): `OrgAddKind` gains Card/Key/Totp; `org edit` becomes per-type interactive dispatch (the old "login/secure-note/identity only" bail is gone).
|
||||
- **Dev-C — org Document + collection-scoped attachments** (merge `4c0a289`): `OrgAddKind::Document`; `org_session.rs` attachment storage (`attachment_path`/`save_attachment`/`load_attachment`/`remove_item_attachments`) writing `attachments/<slug>/<item-id>/<att-id>.enc`; default org attachment cap; `org add document --file` + `org edit --file`; purge removes the item's attachment dir.
|
||||
- **Dev-D — grant-scoped attachment hook** (merge `db4e05a`): `relicario-server` `classify_path` recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (3 segments, slug-only `.`-free guard) as `Item { collection }`, converting attachment writes from `Unrestricted` to grant-scoped — closing a latent authz gap. Bumped `relicario-server` to 0.1.1; `docs/SECURITY.md` documents the required pre-receive hook redeploy.
|
||||
|
||||
Result: `relicario org add`/`edit` now reach **all 7 item types** (Login, Secure Note, Identity, Card, Key, TOTP, Document); org attachments are collection-scoped on disk and grant-enforced at the hook. The C↔D path contract held in the merge — Dev-C's `save_attachment` emitter (`attachments/{slug}/{item}/{att}.enc`) exactly matches Dev-D's `classify_path` authorization. **Deploy note:** the pre-receive hook must be rebuilt on the server for attachment writes to be grant-scoped in production.
|
||||
|
||||
**Still deferred — forward plan in `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`:** extension org **read** (Dev-D) and **write** (Plan B-2) — the extension has no org concept yet; org phase-2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). That parity gap analysis is the authoritative forward plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension gaps: favorites UI, group/tag/filter editing, attachment-remove router wire, per-item purge).
|
||||
|
||||
### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md`
|
||||
@@ -114,10 +127,10 @@ Item CRUD commands (B9–B14): `org add` (`OrgAddKind`: Login/SecureNote/Identit
|
||||
|
||||
**A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed.
|
||||
|
||||
**Tracked follow-ups (deferred, not shipped):**
|
||||
- `org add` / `org edit` parity for Card, SshKey, Document, Totp item types (Login/SecureNote/Identity only today; `get`/`list` can display all types if present)
|
||||
- Extension org-vault switch + read parity (Dev-D deferred)
|
||||
- Extension org write operations
|
||||
**Tracked follow-ups:**
|
||||
- `org add` / `org edit` parity for Card, Key, Document, Totp — ✅ **SHIPPED v0.8.1** (`4c0a289`; all 7 item types now supported)
|
||||
- Extension org-vault switch + read parity (Dev-D) — still deferred; forward plan in the parity gap analysis
|
||||
- Extension org write operations — still deferred (Plan B-2)
|
||||
- Phase 2: SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation), HTTP management plane
|
||||
|
||||
**Known limitations (by design in phase 1):** shared org master key — reads are not cryptographically scoped per collection (hook scopes writes; client filters manifest); no read audit (git records writes only); `delete-org` is a local tombstone only (hook rejects protected-file deletion on push).
|
||||
@@ -169,10 +182,10 @@ Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review
|
||||
|
||||
**Enterprise org vault** — ✅ **COMPLETE (backend)** — all 19 CLI subcommands + core + server hook merged `7392795` 2026-06-20. Deferred follow-ups tracked in the landing section above.
|
||||
|
||||
Pending org-vault follow-ups (in rough priority order):
|
||||
- `org add`/`edit` parity for Card, SshKey, Document, Totp
|
||||
- Extension org switch + read parity (Dev-D)
|
||||
- Extension org write operations
|
||||
Pending follow-ups (in rough priority order; **forward plan:** `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`):
|
||||
- **Extension org parity — read** (Dev-D): org context switch + collection-filtered browse in the popup/vault tab
|
||||
- **Extension org parity — write** (Plan B-2): `org add`/`edit`/`rm` from the extension — blocked behind extension org-read landing (and now unblocked on the CLI side, which reached all-7-type org write in v0.8.1)
|
||||
- **Personal-side extension gaps** (from the parity analysis): favorites UI, group/tag editing on all type forms, popup type/tag filters, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching
|
||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||
|
||||
Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers extension-restructure completion).
|
||||
Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (the `v0.8.1` CHANGELOG entry + version bump are owned by the PM in this lift).
|
||||
|
||||
@@ -24,7 +24,10 @@ under `src/commands/`. Each source file has one job.
|
||||
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
|
||||
Owns the top-level `Cli` / `Commands` enum and every subcommand enum
|
||||
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
|
||||
`DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that
|
||||
`DeviceAction`, `RecoveryQrCmd`), plus the org clap surface `OrgCommands`
|
||||
(`main.rs:448`) and `OrgAddKind` (`main.rs:556`) — the latter's Card / Key /
|
||||
Document / Totp variants carry `--collection` and the `--*-stdin` secret flags.
|
||||
`main()` is a single `match` that
|
||||
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
|
||||
three test-only env-var hooks (`test_passphrase_override`,
|
||||
`test_item_secret_override`, `test_backup_passphrase_override`) — each is
|
||||
@@ -37,15 +40,28 @@ under `src/commands/`. Each source file has one job.
|
||||
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
|
||||
`backup` (export / restore), `import` (lastpass), `attach` (attach /
|
||||
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
|
||||
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
|
||||
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
|
||||
builder/editor reads top-to-bottom and can be tested through the same
|
||||
integration paths.
|
||||
`rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret
|
||||
fields then delegate to the shared `item_build` module's per-`ItemCore`
|
||||
`build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
|
||||
reads top-to-bottom and can be tested through the same integration paths.
|
||||
|
||||
- **`src/commands/item_build.rs`** — shared per-type item construction and
|
||||
interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
|
||||
(`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
|
||||
resolution (`resolve_secret_line` — reads one line from stdin or falls back
|
||||
to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
|
||||
EOF, printing an optional hint in the interactive case); type parsers
|
||||
(`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
|
||||
(`build_login`, `build_secure_note`, `build_identity`, `build_card`,
|
||||
`build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
|
||||
(`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
|
||||
`edit_identity`, `edit_document_message`); and `push_history`.
|
||||
|
||||
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
|
||||
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
|
||||
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
|
||||
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
|
||||
`prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
|
||||
flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
|
||||
`prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to
|
||||
`rpassword`.
|
||||
|
||||
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
|
||||
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
|
||||
@@ -81,7 +97,14 @@ under `src/commands/`. Each source file has one job.
|
||||
(`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive
|
||||
hook authorizes against, never decrypting), fingerprint-based member matching
|
||||
(`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment
|
||||
differences), `atomic_write`, and `org_git_run`. Note `org_git_run` runs
|
||||
differences), `atomic_write`, and `org_git_run`. As of v0.8.1 it also owns
|
||||
**collection-scoped attachment storage** — `attachment_path` /
|
||||
`save_attachment` / `load_attachment` / `remove_item_attachments`
|
||||
(`org_session.rs:125-157`) at layout
|
||||
`attachments/<collection-slug>/<item-id>/<att-id>.enc` (the same leading slug
|
||||
the pre-receive hook authorizes against as for `item_path`), capped
|
||||
per-attachment by `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` (10 MiB,
|
||||
`org_session.rs:20`). Note `org_git_run` runs
|
||||
**bare git** — unlike `helpers::git_run` it does NOT inject
|
||||
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
|
||||
every commit's signature); signing config is established by
|
||||
@@ -98,19 +121,38 @@ under `src/commands/`. Each source file has one job.
|
||||
concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` /
|
||||
`audit` (verified-signer attribution + `TAMPERED` flag).
|
||||
|
||||
*Item CRUD (7):* `org add` creates typed items via `OrgAddKind`
|
||||
(`commands/org.rs:749`) — **Login / SecureNote / Identity only**; Card /
|
||||
SshKey / Document / Totp creation is a deferred follow-up. `get` / `list` can
|
||||
display any item type if present. `org get <query> [--show]` masks secrets
|
||||
unless `--show`; `org list [--trashed]` filters by the caller's collection
|
||||
grants; `org edit <query>` is flag-driven (blank flags keep current values);
|
||||
`org rm` soft-deletes, `org restore` undoes, `org purge` permanently removes
|
||||
the encrypted blob. All item ops are collection-scoped and grant-enforced. The
|
||||
audit trail emits `item-create` / `item-update` / `item-delete` /
|
||||
`item-restore` / `item-purge`.
|
||||
*Item CRUD (7):* full item-type parity with the personal vault (v0.8.1).
|
||||
`org add` creates **all seven types** (Login / SecureNote / Identity / Card /
|
||||
Key / Document / Totp) via `OrgAddKind` (`commands/org.rs:751`); each arm
|
||||
delegates to the shared `item_build::build_*` builders through `build_org_item`
|
||||
(`commands/org.rs:799`), and `run_add` (`commands/org.rs:823`) sets tags
|
||||
post-build. Document is special-cased in `run_add` (`commands/org.rs:839`): its
|
||||
builder also yields an `EncryptedAttachment` that is written via
|
||||
`save_attachment` and git-staged before the signed commit. Single-line secrets
|
||||
(card number/CVV/PIN, TOTP secret, login password) accept a `--*-stdin` flag;
|
||||
multiline secrets (Key material, SecureNote body) read stdin to EOF — the same
|
||||
`resolve_secret_line` / `resolve_secret_multiline` convention as personal `add`
|
||||
(`commands/item_build.rs`).
|
||||
|
||||
Deferred: Card / SshKey / Document / Totp `org add` / `edit` parity;
|
||||
extension org reads and writes (Dev-D).
|
||||
`org edit <query>` (`run_edit`, `commands/org.rs:1004`) is **interactive
|
||||
per-type** as of v0.8.1 (it was flag-driven before): it prompts Title, then
|
||||
dispatches on `&mut item.core` to the shared `item_build::edit_*` helpers
|
||||
("blank keeps current", field-history capture via `push_history`), mirroring
|
||||
personal `cmd_edit`. `--totp-qr` sets a Login TOTP from a QR image; `--file`
|
||||
replaces a Document's primary attachment (`commands/org.rs:1039`, rejected for
|
||||
non-Document items at `commands/org.rs:1018`). The edit commit carries
|
||||
`Relicario-Action: item-update`.
|
||||
|
||||
`org get <query> [--show]` masks every secret unless `--show`; `org list
|
||||
[--trashed]` filters by the caller's collection grants; `org rm` soft-deletes,
|
||||
`org restore` undoes, `org purge` (`run_purge`, `commands/org.rs:1164`)
|
||||
permanently removes the encrypted blob **and** the item's attachment directory
|
||||
(`remove_item_attachments`, `commands/org.rs:1173`). All item ops are
|
||||
collection-scoped and grant-enforced (`filter_for_member` over the manifest +
|
||||
`ensure_grant` before any load/mutate). The audit trail emits `item-create` /
|
||||
`item-update` / `item-delete` / `item-restore` / `item-purge`.
|
||||
|
||||
Deferred: extension org reads and writes (Plan B-2 / phase 2).
|
||||
|
||||
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
||||
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
||||
@@ -167,7 +209,7 @@ in code; cite the line if you change it.
|
||||
works without any setup.
|
||||
|
||||
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
||||
directly; `Item::new` (called inside every `build_*_item`) does it via
|
||||
directly; `Item::new` (called inside every `item_build::build_*`) does it via
|
||||
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
||||
|
||||
- **Manifest is always saved last.** Within a single command, the order is:
|
||||
@@ -237,15 +279,23 @@ in code; cite the line if you change it.
|
||||
### Item add (`cmd_add`, `main.rs:419-456`)
|
||||
|
||||
1. Unlock the vault and load the manifest.
|
||||
2. Match on the `AddKind` variant and dispatch to the matching
|
||||
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
|
||||
builders; only `build_document_item` takes `&UnlockedVault` because it
|
||||
needs `attachment_caps` and writes the encrypted blob alongside the item.
|
||||
3. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||
2. Match on the `AddKind` variant: resolve `title` and non-secret fields
|
||||
(username, URL, holder, expiry, etc.) via `prompt_or_flag` /
|
||||
`prompt_or_flag_optional`, then delegate to the matching `build_*` builder
|
||||
in `commands/item_build.rs`. Seven variants → seven builders; only
|
||||
`build_document` takes `&UnlockedVault` because it needs `attachment_caps`
|
||||
and writes the encrypted blob alongside the item.
|
||||
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
|
||||
accept a `--*-stdin` flag that reads one line from stdin instead of
|
||||
prompting; multiline secrets (SecureNote body, Key material) always read
|
||||
stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
|
||||
Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
|
||||
`resolve_secret_line` / `resolve_secret_multiline`.
|
||||
4. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||
favorite-flag, primary attachment if any).
|
||||
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||
5. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||
`vault.save_manifest(&manifest)`.
|
||||
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||
6. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
||||
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
||||
|
||||
@@ -578,11 +628,12 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
|
||||
instead. Non-primary attachments on a Document (e.g., a scanned
|
||||
contract with an addendum) detach normally.
|
||||
|
||||
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
|
||||
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
|
||||
carried 217-line `match` arms. The split-out functions are easier to
|
||||
read, easier to test individually (the existing integration tests still
|
||||
drive them through the same paths), and easier to grow when a new
|
||||
- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
|
||||
`3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
|
||||
personal and org surfaces share one set). Before the extraction, `cmd_add`
|
||||
and `cmd_edit` carried 217-line `match` arms. The split-out functions are
|
||||
easier to read, easier to test individually (the existing integration tests
|
||||
still drive them through the same paths), and easier to grow when a new
|
||||
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
||||
|
||||
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
description = "CLI for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -1,37 +1,76 @@
|
||||
//! `relicario add <kind>` — create a new item of the given type.
|
||||
//!
|
||||
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
|
||||
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
|
||||
//! `Document` builder is the only one that needs the unlocked vault (for the
|
||||
//! attachment-cap settings + writing the encrypted blob alongside the item).
|
||||
//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the
|
||||
//! shared builders in `commands/item_build.rs`. Group / tags / favorite are
|
||||
//! set AFTER the build so the builders stay portable to the org vault.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::AddKind;
|
||||
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
||||
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
|
||||
use crate::commands::item_build as ib;
|
||||
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
|
||||
|
||||
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
|
||||
let item = match kind {
|
||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
|
||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
||||
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
||||
build_secure_note_item(title, body_prompt, group, tags)?,
|
||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
||||
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
|
||||
AddKind::Card { title, holder, expiry, kind, group, tags } =>
|
||||
build_card_item(title, holder, expiry, kind, group, tags)?,
|
||||
AddKind::Key { title, label, algorithm, group, tags } =>
|
||||
build_key_item(title, label, algorithm, group, tags)?,
|
||||
AddKind::Document { title, file, group, tags } =>
|
||||
build_document_item(&vault, title, file, group, tags)?,
|
||||
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
|
||||
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
|
||||
AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
||||
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?;
|
||||
item.group = group; item.tags = tags; item.favorite = favorite;
|
||||
item
|
||||
}
|
||||
AddKind::SecureNote { title, body_stdin, group, tags } => {
|
||||
// Per the v0.8.1 spec's unified secret model, a note body is a
|
||||
// multiline secret that always reads stdin to EOF. `body_stdin=false`
|
||||
// means "print the Ctrl-D hint" (interactive default); `true` suppresses
|
||||
// the hint for non-interactive use.
|
||||
// Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`.
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_secure_note(title, None, body_stdin)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_identity(title, full_name, email, phone, date_of_birth)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Key { title, label, algorithm, material_stdin, group, tags } => {
|
||||
// public_key is None for the personal vault: the legacy `prompt_optional`
|
||||
// for it was unreachable (stdin already at EOF after the key-material read).
|
||||
// Org `add key` (Dev-B) supplies it via --public-key.
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Document { title, file, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let caps = vault.load_settings()?.attachment_caps;
|
||||
let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
item.group = group; item.tags = tags;
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
std::fs::create_dir_all(&att_dir)?;
|
||||
std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||
item
|
||||
}
|
||||
AddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
};
|
||||
|
||||
vault.save_item(&item)?;
|
||||
@@ -51,263 +90,3 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_login_item(
|
||||
title: Option<String>,
|
||||
username: Option<String>,
|
||||
url: Option<String>,
|
||||
password_prompt: bool,
|
||||
password: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
favorite: bool,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
||||
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
||||
let parsed_url = match url {
|
||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||
None => None,
|
||||
};
|
||||
let password = if let Some(p) = password {
|
||||
Some(Zeroizing::new(p))
|
||||
} else if password_prompt {
|
||||
Some(Zeroizing::new(prompt_secret("Password: ")?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let totp = if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
||||
username, password, url: parsed_url, totp,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
item.favorite = favorite;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_secure_note_item(
|
||||
title: Option<String>,
|
||||
body_prompt: bool,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::SecureNoteCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let body = if body_prompt {
|
||||
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
s
|
||||
} else {
|
||||
prompt("Body")?
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new(body),
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_identity_item(
|
||||
title: Option<String>,
|
||||
full_name: Option<String>,
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
date_of_birth: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::IdentityCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let dob = match date_of_birth {
|
||||
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||
None => None,
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
|
||||
full_name, address: None, phone, email, date_of_birth: dob,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_card_item(
|
||||
title: Option<String>,
|
||||
holder: Option<String>,
|
||||
expiry: Option<String>,
|
||||
kind: String,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{CardCore, CardKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
||||
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
||||
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
||||
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
|
||||
let pin = if pin.is_empty() { None } else { Some(pin) };
|
||||
|
||||
let parsed_expiry = match expiry {
|
||||
Some(s) => Some(parse_month_year(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let parsed_kind = match kind.as_str() {
|
||||
"credit" => CardKind::Credit,
|
||||
"debit" => CardKind::Debit,
|
||||
"gift" => CardKind::Gift,
|
||||
"loyalty" => CardKind::Loyalty,
|
||||
"other" => CardKind::Other,
|
||||
other => anyhow::bail!("unknown card kind: {other}"),
|
||||
};
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Card(CardCore {
|
||||
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_key_item(
|
||||
title: Option<String>,
|
||||
label: Option<String>,
|
||||
algorithm: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::KeyCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
||||
let mut key_material = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
||||
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||
let public_key = prompt_optional("Public key (blank to skip)")?;
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Key(KeyCore {
|
||||
key_material: Zeroizing::new(key_material),
|
||||
label, public_key, algorithm,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_document_item(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
title: Option<String>,
|
||||
file: PathBuf,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::DocumentCore;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||||
use std::fs;
|
||||
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let bytes = fs::read(&file)
|
||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||
let caps = vault.load_settings()?.attachment_caps;
|
||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = guess_mime(&filename);
|
||||
|
||||
let primary_attachment = enc.id.clone();
|
||||
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||
filename: filename.clone(),
|
||||
mime_type: mime_type.clone(),
|
||||
primary_attachment: primary_attachment.clone(),
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
item.attachments.push(AttachmentRef {
|
||||
id: primary_attachment.clone(),
|
||||
filename, mime_type,
|
||||
size: bytes.len() as u64,
|
||||
created: item.created,
|
||||
});
|
||||
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
fs::create_dir_all(&att_dir)?;
|
||||
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_totp_item(
|
||||
title: Option<String>,
|
||||
issuer: Option<String>,
|
||||
label: Option<String>,
|
||||
secret: Option<String>,
|
||||
period: u32,
|
||||
digits: u8,
|
||||
algorithm: String,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let secret_b32 = match secret {
|
||||
Some(s) => s,
|
||||
None => prompt_secret("TOTP secret (base32): ")?,
|
||||
};
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
let algo = match algorithm.to_ascii_lowercase().as_str() {
|
||||
"sha1" => TotpAlgorithm::Sha1,
|
||||
"sha256" => TotpAlgorithm::Sha256,
|
||||
"sha512" => TotpAlgorithm::Sha512,
|
||||
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||
};
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
|
||||
config: TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: algo,
|
||||
digits,
|
||||
period_seconds: period,
|
||||
kind: TotpKind::Totp,
|
||||
},
|
||||
issuer, label,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::parse::base32_decode_lenient;
|
||||
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||
use crate::prompt::{prompt_keep, prompt_keep_opt};
|
||||
|
||||
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
@@ -29,13 +28,13 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
|
||||
let history = &mut item.field_history;
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => edit_identity(i)?,
|
||||
ItemCore::Card(c) => edit_card(c, history)?,
|
||||
ItemCore::Key(k) => edit_key(k, history)?,
|
||||
ItemCore::Document(_) => edit_document_message(),
|
||||
ItemCore::Totp(t) => edit_totp(t, history)?,
|
||||
ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?,
|
||||
ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?,
|
||||
ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?,
|
||||
ItemCore::Document(_) => crate::commands::item_build::edit_document_message(),
|
||||
ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?,
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
@@ -47,125 +46,3 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
eprintln!("Updated {}", item.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
|
||||
// that touch history-tracked fields take the item's field_history map so
|
||||
// they can record the prior value alongside the change.
|
||||
|
||||
type FieldHistory = std::collections::HashMap<
|
||||
relicario_core::FieldId,
|
||||
Vec<relicario_core::item::FieldHistoryEntry>,
|
||||
>;
|
||||
|
||||
fn edit_login(
|
||||
l: &mut relicario_core::item_types::LoginCore,
|
||||
history: &mut FieldHistory,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||
}
|
||||
if prompt_yesno("Change password?")? {
|
||||
let old = l.password.clone();
|
||||
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||
if let Some(old_pw) = old {
|
||||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
l.totp = Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
});
|
||||
eprintln!("TOTP secret set from QR image.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if prompt_yesno("Edit body?")? {
|
||||
let old = n.body.clone();
|
||||
eprintln!("Enter new body; end with Ctrl-D:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
n.body = Zeroizing::new(s);
|
||||
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||
if prompt_yesno("Change card number?")? {
|
||||
let old = c.number.clone();
|
||||
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||
if let Some(o) = old {
|
||||
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if prompt_yesno("Replace key material?")? {
|
||||
eprintln!("Paste new key material; end with Ctrl-D:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
let old = k.key_material.clone();
|
||||
k.key_material = Zeroizing::new(s);
|
||||
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_document_message() {
|
||||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||
}
|
||||
|
||||
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||
if prompt_yesno("Change TOTP secret?")? {
|
||||
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||
t.config.secret = Zeroizing::new(new_bytes);
|
||||
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_history(
|
||||
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
|
||||
synthetic_key: &str,
|
||||
old_value: zeroize::Zeroizing<String>,
|
||||
) {
|
||||
use relicario_core::item::FieldHistoryEntry;
|
||||
use relicario_core::time::now_unix;
|
||||
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||||
// custom-field UUIDs can't collide).
|
||||
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||||
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||||
value: old_value,
|
||||
replaced_at: now_unix(),
|
||||
});
|
||||
}
|
||||
|
||||
334
crates/relicario-cli/src/commands/item_build.rs
Normal file
334
crates/relicario-cli/src/commands/item_build.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
//! Shared per-type item construction + interactive editing for both the
|
||||
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
|
||||
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::item::FieldHistoryEntry;
|
||||
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
|
||||
|
||||
use crate::parse::base32_decode_lenient;
|
||||
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||
|
||||
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
|
||||
|
||||
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
|
||||
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
|
||||
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
|
||||
if from_stdin {
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
Ok(s.trim_end_matches(['\n', '\r']).to_string())
|
||||
} else {
|
||||
crate::prompt::prompt_secret(&format!("{label}: "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a multiline secret (key material, note body). Both paths read stdin
|
||||
/// to EOF; the interactive path first prints `hint` to stderr.
|
||||
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
|
||||
if !from_stdin {
|
||||
eprintln!("{hint}");
|
||||
}
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
|
||||
Ok(match s {
|
||||
"credit" => CardKind::Credit,
|
||||
"debit" => CardKind::Debit,
|
||||
"gift" => CardKind::Gift,
|
||||
"loyalty" => CardKind::Loyalty,
|
||||
"other" => CardKind::Other,
|
||||
other => anyhow::bail!("unknown card kind: {other}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
|
||||
Ok(match s.to_ascii_lowercase().as_str() {
|
||||
"sha1" => TotpAlgorithm::Sha1,
|
||||
"sha256" => TotpAlgorithm::Sha256,
|
||||
"sha512" => TotpAlgorithm::Sha512,
|
||||
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
|
||||
// mutates its core slice in place; history-tracked variants take the
|
||||
// item's field_history map so they can record the prior value.
|
||||
|
||||
pub(crate) fn edit_login(
|
||||
l: &mut relicario_core::item_types::LoginCore,
|
||||
history: &mut FieldHistory,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::item_types::{TotpConfig, TotpKind};
|
||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||
}
|
||||
if prompt_yesno("Change password?")? {
|
||||
let old = l.password.clone();
|
||||
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||
if let Some(old_pw) = old {
|
||||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
l.totp = Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
});
|
||||
eprintln!("TOTP secret set from QR image.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||
if prompt_yesno("Edit body?")? {
|
||||
let old = n.body.clone();
|
||||
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
|
||||
n.body = Zeroizing::new(s);
|
||||
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||
if prompt_yesno("Change card number?")? {
|
||||
let old = c.number.clone();
|
||||
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||
if let Some(o) = old {
|
||||
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||
if prompt_yesno("Replace key material?")? {
|
||||
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
|
||||
let old = k.key_material.clone();
|
||||
k.key_material = Zeroizing::new(s);
|
||||
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_document_message() {
|
||||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||
}
|
||||
|
||||
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||
if prompt_yesno("Change TOTP secret?")? {
|
||||
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||
t.config.secret = Zeroizing::new(new_bytes);
|
||||
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn build_login(
|
||||
title: String, username: Option<String>, url: Option<String>,
|
||||
password: Option<String>, password_stdin: bool, password_prompt: bool,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
|
||||
let parsed_url = match url {
|
||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||
None => None,
|
||||
};
|
||||
let password = if let Some(p) = password {
|
||||
Some(Zeroizing::new(p))
|
||||
} else if password_stdin {
|
||||
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
|
||||
} else if password_prompt {
|
||||
Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let totp = if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6, period_seconds: 30, kind: TotpKind::Totp,
|
||||
})
|
||||
} else { None };
|
||||
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
|
||||
}
|
||||
|
||||
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
|
||||
use relicario_core::item_types::SecureNoteCore;
|
||||
let body = match body {
|
||||
Some(b) => b,
|
||||
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
|
||||
};
|
||||
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
|
||||
}
|
||||
|
||||
pub(crate) fn build_identity(
|
||||
title: String, full_name: Option<String>, email: Option<String>,
|
||||
phone: Option<String>, date_of_birth: Option<String>,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::IdentityCore;
|
||||
let dob = match date_of_birth {
|
||||
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
|
||||
full_name, address: None, phone, email, date_of_birth: dob,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn build_card(
|
||||
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
|
||||
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::CardCore;
|
||||
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
|
||||
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
|
||||
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
|
||||
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
|
||||
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
|
||||
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
|
||||
Ok(Item::new(title, ItemCore::Card(CardCore {
|
||||
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn build_key(
|
||||
title: String, label: Option<String>, algorithm: Option<String>,
|
||||
public_key: Option<String>, material_stdin: bool,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::KeyCore;
|
||||
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
|
||||
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||
Ok(Item::new(title, ItemCore::Key(KeyCore {
|
||||
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
|
||||
})))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn build_totp(
|
||||
title: String, issuer: Option<String>, label: Option<String>,
|
||||
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
|
||||
let secret_b32 = match secret {
|
||||
Some(s) => s,
|
||||
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
|
||||
};
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Ok(Item::new(title, ItemCore::Totp(TotpCore {
|
||||
config: TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
|
||||
digits, period_seconds: period, kind: TotpKind::Totp,
|
||||
},
|
||||
issuer, label,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Read a file and encrypt it as an attachment under `key`, deriving its display
|
||||
/// metadata. The plaintext is held in a `Zeroizing` buffer so it is wiped after
|
||||
/// encryption. Returns the encrypted blob plus (filename, mime_type, size).
|
||||
pub(crate) fn encrypt_document_file(
|
||||
path: &Path,
|
||||
key: &Zeroizing<[u8; 32]>,
|
||||
max_bytes: u64,
|
||||
) -> Result<(EncryptedAttachment, String, String, u64)> {
|
||||
use relicario_core::encrypt_attachment;
|
||||
let bytes = Zeroizing::new(
|
||||
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
|
||||
);
|
||||
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = crate::parse::guess_mime(&filename);
|
||||
Ok((enc, filename, mime_type, bytes.len() as u64))
|
||||
}
|
||||
|
||||
pub(crate) fn build_document(
|
||||
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
|
||||
) -> Result<(Item, EncryptedAttachment)> {
|
||||
use relicario_core::item_types::DocumentCore;
|
||||
use relicario_core::AttachmentRef;
|
||||
let (enc, filename, mime_type, size) = encrypt_document_file(&file, key, max_bytes)?;
|
||||
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: enc.id.clone(),
|
||||
}));
|
||||
item.attachments.push(AttachmentRef {
|
||||
id: enc.id.clone(), filename, mime_type, size, created: item.created,
|
||||
});
|
||||
Ok((item, enc))
|
||||
}
|
||||
|
||||
pub(crate) fn push_history(
|
||||
history: &mut FieldHistory,
|
||||
synthetic_key: &str,
|
||||
old_value: zeroize::Zeroizing<String>,
|
||||
) {
|
||||
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||||
// custom-field UUIDs can't collide).
|
||||
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||||
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||||
value: old_value,
|
||||
replaced_at: now_unix(),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||
|
||||
#[test]
|
||||
fn card_kind_parses_known_values() {
|
||||
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
|
||||
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_kind_rejects_unknown() {
|
||||
assert!(parse_card_kind("platinum").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_algorithm_is_case_insensitive() {
|
||||
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_algorithm_rejects_unknown() {
|
||||
assert!(parse_totp_algorithm("md5").is_err());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod edit;
|
||||
pub mod generate;
|
||||
pub mod get;
|
||||
pub mod import;
|
||||
pub mod item_build;
|
||||
pub mod org;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
|
||||
@@ -6,12 +6,13 @@ use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use relicario_core::{
|
||||
generate_org_key, wrap_org_key,
|
||||
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
||||
CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
||||
OrgRole, OrgMember,
|
||||
encrypt_org_manifest,
|
||||
};
|
||||
|
||||
use crate::org_session::atomic_write;
|
||||
use crate::commands::item_build as ib;
|
||||
|
||||
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
|
||||
// Create directory structure
|
||||
@@ -745,17 +746,20 @@ pub fn run_audit(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Item kinds `org add` supports without interactive prompts.
|
||||
/// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
|
||||
/// interactive prompt inside the shared `item_build` builders.
|
||||
pub enum OrgAddKind {
|
||||
Login {
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
url: Option<String>,
|
||||
password: Option<String>,
|
||||
password_stdin: bool,
|
||||
},
|
||||
SecureNote {
|
||||
title: String,
|
||||
body: String,
|
||||
body: Option<String>,
|
||||
body_stdin: bool,
|
||||
},
|
||||
Identity {
|
||||
title: String,
|
||||
@@ -763,43 +767,57 @@ pub enum OrgAddKind {
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
},
|
||||
Card {
|
||||
title: String,
|
||||
holder: Option<String>,
|
||||
expiry: Option<String>,
|
||||
kind: String,
|
||||
number_stdin: bool,
|
||||
cvv_stdin: bool,
|
||||
pin_stdin: bool,
|
||||
},
|
||||
Key {
|
||||
title: String,
|
||||
label: Option<String>,
|
||||
algorithm: Option<String>,
|
||||
public_key: Option<String>,
|
||||
material_stdin: bool,
|
||||
},
|
||||
Totp {
|
||||
title: String,
|
||||
issuer: Option<String>,
|
||||
label: Option<String>,
|
||||
secret: Option<String>,
|
||||
secret_stdin: bool,
|
||||
period: u32,
|
||||
digits: u8,
|
||||
algorithm: String,
|
||||
},
|
||||
Document { title: String, file: std::path::PathBuf },
|
||||
}
|
||||
|
||||
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
|
||||
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let mut item = match kind {
|
||||
OrgAddKind::Login { title, username, url, password } => {
|
||||
let parsed_url = match url {
|
||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||
None => None,
|
||||
};
|
||||
let password = password.map(Zeroizing::new);
|
||||
Item::new(title, ItemCore::Login(LoginCore {
|
||||
username,
|
||||
password,
|
||||
url: parsed_url,
|
||||
totp: None,
|
||||
}))
|
||||
fn build_org_item(kind: OrgAddKind) -> Result<Item> {
|
||||
match kind {
|
||||
OrgAddKind::Login { title, username, url, password, password_stdin } => {
|
||||
ib::build_login(title, username, url, password, password_stdin, false, None)
|
||||
}
|
||||
OrgAddKind::SecureNote { title, body } => {
|
||||
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new(body),
|
||||
}))
|
||||
OrgAddKind::SecureNote { title, body, body_stdin } => {
|
||||
ib::build_secure_note(title, body, body_stdin)
|
||||
}
|
||||
OrgAddKind::Identity { title, full_name, email, phone } => {
|
||||
Item::new(title, ItemCore::Identity(IdentityCore {
|
||||
full_name,
|
||||
address: None,
|
||||
phone,
|
||||
email,
|
||||
date_of_birth: None,
|
||||
}))
|
||||
ib::build_identity(title, full_name, email, phone, None)
|
||||
}
|
||||
};
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => {
|
||||
ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)
|
||||
}
|
||||
OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => {
|
||||
ib::build_key(title, label, algorithm, public_key, material_stdin)
|
||||
}
|
||||
OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
|
||||
ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
|
||||
}
|
||||
OrgAddKind::Document { .. } => unreachable!("Document handled in run_add before build_org_item"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
|
||||
@@ -816,7 +834,17 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
|
||||
// …and the caller must hold a grant for it.
|
||||
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
||||
|
||||
let item = build_org_item(kind, tags)?;
|
||||
// Build the item; Document additionally yields an encrypted attachment to persist.
|
||||
let (mut item, attachment_rel): (relicario_core::Item, Option<String>) = match kind {
|
||||
OrgAddKind::Document { title, file } => {
|
||||
let (item, enc) = ib::build_document(
|
||||
title, file, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
|
||||
let rel = vault.save_attachment(collection, &item.id, &enc)?;
|
||||
(item, Some(rel))
|
||||
}
|
||||
other => (build_org_item(other)?, None),
|
||||
};
|
||||
item.tags = tags;
|
||||
let item_rel = vault.save_item(collection, &item)?;
|
||||
|
||||
// Upsert the manifest entry (collection slug stored plaintext inside the
|
||||
@@ -837,11 +865,11 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
|
||||
collection,
|
||||
item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(
|
||||
&vault.root,
|
||||
&["add", &item_rel, "manifest.enc"],
|
||||
"org add: git add",
|
||||
)?;
|
||||
let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
|
||||
if let Some(ref rel) = attachment_rel {
|
||||
add_args.insert(1, rel);
|
||||
}
|
||||
crate::org_session::org_git_run(&vault.root, &add_args, "org add: git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
|
||||
|
||||
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
|
||||
@@ -973,21 +1001,9 @@ fn resolve_org_query<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_edit(
|
||||
dir: &Path,
|
||||
query: &str,
|
||||
title: Option<String>,
|
||||
username: Option<String>,
|
||||
url: Option<String>,
|
||||
password: Option<String>,
|
||||
body: Option<String>,
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
full_name: Option<String>,
|
||||
) -> Result<()> {
|
||||
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, file: Option<std::path::PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
@@ -999,31 +1015,56 @@ pub fn run_edit(
|
||||
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
||||
|
||||
let mut item = vault.load_item(&collection, &id)?;
|
||||
if file.is_some() && !matches!(item.core, ItemCore::Document(_)) {
|
||||
anyhow::bail!("--file is only valid when editing a Document item");
|
||||
}
|
||||
eprintln!(
|
||||
"Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||
item.title,
|
||||
item.id.as_str()
|
||||
);
|
||||
if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? {
|
||||
item.title = v;
|
||||
}
|
||||
|
||||
if let Some(t) = title { item.title = t; }
|
||||
|
||||
let history = &mut item.field_history;
|
||||
let mut doc_attachment_rel: Option<String> = None;
|
||||
let mut new_doc_attachments: Option<Vec<relicario_core::AttachmentRef>> = None;
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = username { l.username = Some(u); }
|
||||
if let Some(u) = url {
|
||||
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
|
||||
ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => ib::edit_identity(i)?,
|
||||
ItemCore::Card(c) => ib::edit_card(c, history)?,
|
||||
ItemCore::Key(k) => ib::edit_key(k, history)?,
|
||||
ItemCore::Document(d) => {
|
||||
if let Some(path) = &file {
|
||||
let (enc, filename, mime_type, size) = ib::encrypt_document_file(
|
||||
path, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
|
||||
vault.remove_item_attachments(&collection, &id)?;
|
||||
let rel = vault.save_attachment(&collection, &id, &enc)?;
|
||||
d.filename = filename.clone();
|
||||
d.mime_type = mime_type.clone();
|
||||
d.primary_attachment = enc.id.clone();
|
||||
new_doc_attachments = Some(vec![relicario_core::AttachmentRef {
|
||||
id: enc.id,
|
||||
filename,
|
||||
mime_type,
|
||||
size,
|
||||
created: now_unix(),
|
||||
}]);
|
||||
doc_attachment_rel = Some(rel);
|
||||
} else {
|
||||
ib::edit_document_message();
|
||||
}
|
||||
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if let Some(b) = body { n.body = Zeroizing::new(b); }
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = full_name { i.full_name = Some(v); }
|
||||
if let Some(v) = email { i.email = Some(v); }
|
||||
if let Some(v) = phone { i.phone = Some(v); }
|
||||
}
|
||||
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
|
||||
ItemCore::Totp(t) => ib::edit_totp(t, history)?,
|
||||
}
|
||||
if let Some(atts) = new_doc_attachments {
|
||||
item.attachments = atts;
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
let item_rel = vault.save_item(&collection, &item)?;
|
||||
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
upsert_org_entry(&mut manifest, &item, &collection);
|
||||
vault.save_manifest(&manifest)?;
|
||||
@@ -1035,12 +1076,20 @@ pub fn run_edit(
|
||||
);
|
||||
let commit_msg = format!(
|
||||
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
|
||||
caller.display_name,
|
||||
caller.member_id.as_str(),
|
||||
collection,
|
||||
item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
|
||||
let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
|
||||
let att_dir_rel;
|
||||
if doc_attachment_rel.is_some() {
|
||||
att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
|
||||
add_args.push(&att_dir_rel);
|
||||
}
|
||||
crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
|
||||
|
||||
println!("Updated {}", item.id.as_str());
|
||||
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1114,12 +1163,14 @@ pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
|
||||
|
||||
// Remove the blob from disk, drop the manifest entry, stage with git rm.
|
||||
vault.remove_item(&collection, &id)?;
|
||||
vault.remove_item_attachments(&collection, &id)?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
manifest.entries.retain(|e| e.id != id);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
|
||||
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
|
||||
let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
|
||||
crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_rel], "org purge: git rm")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
|
||||
|
||||
let commit_msg = format!(
|
||||
|
||||
@@ -227,6 +227,8 @@ pub(crate) enum AddKind {
|
||||
/// Prompt for password (vs reading from stdin or --password).
|
||||
#[arg(long)] password_prompt: bool,
|
||||
#[arg(long)] password: Option<String>,
|
||||
/// Read the password from stdin (one line) instead of prompting.
|
||||
#[arg(long)] password_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] favorite: bool,
|
||||
@@ -235,7 +237,8 @@ pub(crate) enum AddKind {
|
||||
},
|
||||
SecureNote {
|
||||
#[arg(long)] title: Option<String>,
|
||||
#[arg(long)] body_prompt: bool,
|
||||
/// Read the note body from stdin (to EOF) instead of printing the Ctrl-D hint.
|
||||
#[arg(long)] body_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
@@ -253,6 +256,12 @@ pub(crate) enum AddKind {
|
||||
#[arg(long)] holder: Option<String>,
|
||||
#[arg(long)] expiry: Option<String>, // MM/YYYY
|
||||
#[arg(long, default_value = "credit")] kind: String,
|
||||
/// Read the card number from stdin (one line) instead of prompting.
|
||||
#[arg(long)] number_stdin: bool,
|
||||
/// Read the CVV from stdin (one line) instead of prompting.
|
||||
#[arg(long)] cvv_stdin: bool,
|
||||
/// Read the PIN from stdin (one line) instead of prompting.
|
||||
#[arg(long)] pin_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
@@ -260,6 +269,8 @@ pub(crate) enum AddKind {
|
||||
#[arg(long)] title: Option<String>,
|
||||
#[arg(long)] label: Option<String>,
|
||||
#[arg(long)] algorithm: Option<String>,
|
||||
/// Read the key material from stdin (to EOF) instead of printing the Ctrl-D hint.
|
||||
#[arg(long)] material_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
@@ -274,6 +285,8 @@ pub(crate) enum AddKind {
|
||||
#[arg(long)] issuer: Option<String>,
|
||||
#[arg(long)] label: Option<String>,
|
||||
#[arg(long)] secret: Option<String>, // base32
|
||||
/// Read the TOTP secret from stdin (one line) instead of prompting.
|
||||
#[arg(long)] secret_stdin: bool,
|
||||
#[arg(long, default_value = "30")] period: u32,
|
||||
#[arg(long, default_value = "6")] digits: u8,
|
||||
#[arg(long, default_value = "sha1")] algorithm: String,
|
||||
@@ -522,18 +535,14 @@ pub(crate) enum OrgCommands {
|
||||
List {
|
||||
#[arg(long)] trashed: bool,
|
||||
},
|
||||
/// Edit an org item's fields (flag-driven; blank flags keep current values).
|
||||
/// Edit an org item interactively (per-type prompts; blank keeps current).
|
||||
Edit {
|
||||
/// Item id or case-insensitive title substring.
|
||||
query: String,
|
||||
#[arg(long)] title: Option<String>,
|
||||
#[arg(long)] username: Option<String>,
|
||||
#[arg(long)] url: Option<String>,
|
||||
#[arg(long)] password: Option<String>,
|
||||
#[arg(long)] body: Option<String>,
|
||||
#[arg(long)] email: Option<String>,
|
||||
#[arg(long)] phone: Option<String>,
|
||||
#[arg(long)] full_name: Option<String>,
|
||||
/// Replace the login TOTP secret from a QR image.
|
||||
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
|
||||
/// Replace a Document item's attachment file.
|
||||
#[arg(long)] file: Option<std::path::PathBuf>,
|
||||
},
|
||||
/// Soft-delete an org item (reversible via `org restore`).
|
||||
Rm { query: String },
|
||||
@@ -553,13 +562,15 @@ pub(crate) enum OrgAddKind {
|
||||
#[arg(long)] url: Option<String>,
|
||||
#[arg(long)] password: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] password_stdin: bool,
|
||||
},
|
||||
/// A secure note.
|
||||
SecureNote {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] body: String,
|
||||
#[arg(long)] body: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] body_stdin: bool,
|
||||
},
|
||||
/// An identity record.
|
||||
Identity {
|
||||
@@ -570,6 +581,48 @@ pub(crate) enum OrgAddKind {
|
||||
#[arg(long)] phone: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
/// A payment card (number / cvv / pin entered via --*-stdin or prompt).
|
||||
Card {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] holder: Option<String>,
|
||||
#[arg(long)] expiry: Option<String>,
|
||||
#[arg(long, default_value = "credit")] kind: String,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] number_stdin: bool,
|
||||
#[arg(long)] cvv_stdin: bool,
|
||||
#[arg(long)] pin_stdin: bool,
|
||||
},
|
||||
/// A key / credential blob (material entered via --material-stdin or prompt).
|
||||
Key {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] label: Option<String>,
|
||||
#[arg(long)] algorithm: Option<String>,
|
||||
#[arg(long)] public_key: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] material_stdin: bool,
|
||||
},
|
||||
/// A TOTP authenticator (base32 secret via --secret or --secret-stdin).
|
||||
Totp {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] issuer: Option<String>,
|
||||
#[arg(long)] label: Option<String>,
|
||||
#[arg(long)] secret: Option<String>,
|
||||
#[arg(long, default_value_t = 30)] period: u32,
|
||||
#[arg(long, default_value_t = 6)] digits: u8,
|
||||
#[arg(long, default_value = "sha1")] algorithm: String,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] secret_stdin: bool,
|
||||
},
|
||||
/// A document (file payload encrypted into a collection-scoped attachment).
|
||||
Document {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] file: std::path::PathBuf,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -663,14 +716,14 @@ fn main() -> Result<()> {
|
||||
OrgCommands::Add { kind } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
let (collection, add_kind, tags) = match kind {
|
||||
OrgAddKind::Login { collection, title, username, url, password, tags } => (
|
||||
OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Login { title, username, url, password },
|
||||
commands::org::OrgAddKind::Login { title, username, url, password, password_stdin },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::SecureNote { collection, title, body, tags } => (
|
||||
OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::SecureNote { title, body },
|
||||
commands::org::OrgAddKind::SecureNote { title, body, body_stdin },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
|
||||
@@ -678,6 +731,26 @@ fn main() -> Result<()> {
|
||||
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::Document { collection, title, file, tags } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Document { title, file },
|
||||
tags,
|
||||
),
|
||||
};
|
||||
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
||||
}
|
||||
@@ -689,9 +762,9 @@ fn main() -> Result<()> {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_list(&d, trashed)?;
|
||||
}
|
||||
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
|
||||
OrgCommands::Edit { query, totp_qr, file } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
|
||||
commands::org::run_edit(&d, &query, totp_qr, file)?;
|
||||
}
|
||||
OrgCommands::Rm { query } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
|
||||
@@ -9,9 +9,16 @@ use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::{
|
||||
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
|
||||
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
|
||||
AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest,
|
||||
OrgMember, OrgMembers, OrgMeta,
|
||||
};
|
||||
|
||||
/// Default per-attachment cap for org vaults. Org vaults have no settings.enc,
|
||||
/// so this mirrors the personal-vault default
|
||||
/// `AttachmentCaps::per_attachment_max_bytes` at
|
||||
/// crates/relicario-core/src/settings.rs:116.
|
||||
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
pub struct UnlockedOrgVault {
|
||||
pub root: PathBuf,
|
||||
pub org_key: Zeroizing<[u8; 32]>,
|
||||
@@ -115,6 +122,40 @@ impl UnlockedOrgVault {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
|
||||
self.root.join("attachments").join(collection_slug)
|
||||
.join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
|
||||
}
|
||||
|
||||
/// Encrypt-already-done blob: persist it and return the repo-relative path for git staging.
|
||||
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
|
||||
let path = self.attachment_path(collection_slug, item_id, &enc.id);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
atomic_write(&path, &enc.bytes)?;
|
||||
Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
|
||||
}
|
||||
|
||||
// Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention).
|
||||
#[allow(dead_code)]
|
||||
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let path = self.attachment_path(collection_slug, item_id, att_id);
|
||||
let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
|
||||
Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
|
||||
}
|
||||
|
||||
/// Remove an item's whole attachment directory. Missing dir is NOT an error
|
||||
/// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
|
||||
pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
|
||||
let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
|
||||
match fs::remove_dir_all(&dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bail unless `member` has `slug` in their collection grants. The slug
|
||||
/// existence check is done separately by the caller against collections.json.
|
||||
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
|
||||
@@ -292,6 +333,22 @@ mod tests {
|
||||
assert_eq!(loaded.entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_round_trip_collection_scoped() {
|
||||
use relicario_core::encrypt_attachment;
|
||||
let key = Zeroizing::new([7u8; 32]);
|
||||
let (dir, vault) = make_vault(key);
|
||||
let _ = dir; // keep tempdir alive
|
||||
let item_id = ItemId::new();
|
||||
let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
|
||||
let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
|
||||
assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
|
||||
let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
|
||||
assert_eq!(got.as_slice(), b"hello world");
|
||||
vault.remove_item_attachments("eng", &item_id).unwrap();
|
||||
assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_members() {
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
//! Interactive prompt helpers for the CLI.
|
||||
//!
|
||||
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
|
||||
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
||||
//! `prompt_secret` reads a masked secret from the TTY (honouring
|
||||
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject
|
||||
//! secrets); the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
||||
//! used by the edit handlers to keep current values when the user hits enter
|
||||
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
||||
//! so integration tests (which don't have a TTY) can inject secrets.
|
||||
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
|
||||
//! through the same path so command handlers can use one call site whether
|
||||
//! the value came from the command line or from an interactive prompt.
|
||||
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
|
||||
//! CLI-flag value through the same path so command handlers can use one call
|
||||
//! site whether the value came from the command line or an interactive prompt.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::io::BufRead;
|
||||
@@ -41,18 +40,6 @@ fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<
|
||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||
read_required_line(&mut reader, label)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||
read_optional_line(&mut reader, label)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||
eprint!("{label} [{current}]: ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
|
||||
@@ -201,3 +201,20 @@ fn generate_random_and_bip39() {
|
||||
let phrase = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(phrase.trim().split(' ').count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_card_via_stdin_flags_is_non_interactive() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run_with_input(
|
||||
&["add", "card", "--title", "Visa", "--kind", "credit",
|
||||
"--number-stdin", "--cvv-stdin", "--pin-stdin"],
|
||||
&["4111111111111111", "123", "4321"],
|
||||
);
|
||||
assert!(out.status.success(), "add card via stdin failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = v.run(&["get", "Visa"]);
|
||||
assert!(got.status.success(), "get Visa failed: {}", String::from_utf8_lossy(&got.stderr));
|
||||
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||
assert!(stdout.contains("********"), "card number should be masked without --show: {stdout}");
|
||||
assert!(!stdout.contains("4111111111111111"), "card number leaked without --show: {stdout}");
|
||||
}
|
||||
|
||||
@@ -152,7 +152,9 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
||||
);
|
||||
|
||||
for (label, args) in [
|
||||
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]),
|
||||
// `org edit` is now interactive (no flat flags); the ungranted member is
|
||||
// rejected at manifest lookup, before any prompt is read.
|
||||
("edit", vec!["org", "edit", "GitHub"]),
|
||||
("rm", vec!["org", "rm", "GitHub"]),
|
||||
("restore", vec!["org", "restore", "GitHub"]),
|
||||
("purge", vec!["org", "purge", "GitHub"]),
|
||||
@@ -170,13 +172,12 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
||||
}
|
||||
|
||||
// The item is untouched: the owner can still read the original password and
|
||||
// the username was NOT changed to the ungranted member's "evil" attempt.
|
||||
// username — the ungranted member's get/edit/rm/restore/purge were all denied.
|
||||
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
||||
assert!(owner_get.status.success(), "owner should still read the item");
|
||||
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
||||
assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}");
|
||||
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
|
||||
assert!(owner_out.contains("alice"), "ungranted member must not have modified the item: {owner_out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -67,6 +67,39 @@ impl OrgFixture {
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
|
||||
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
|
||||
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
|
||||
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
|
||||
use std::io::Write as _;
|
||||
let mut child = Command::cargo_bin("relicario")
|
||||
.unwrap()
|
||||
.env("XDG_CONFIG_HOME", &self.xdg)
|
||||
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// Create collection `slug` and grant the owner access to it — the common
|
||||
/// setup the item-type round-trip tests share.
|
||||
fn create_collection_and_grant(&self, slug: &str) {
|
||||
let owner = self.owner_member_id();
|
||||
assert!(
|
||||
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
|
||||
"create-collection {slug} failed",
|
||||
);
|
||||
assert!(
|
||||
self.run(&["org", "grant", &owner, slug]).status.success(),
|
||||
"grant {slug} failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -151,21 +184,17 @@ fn org_add_rejects_unknown_collection() {
|
||||
#[test]
|
||||
fn org_edit_updates_fields_and_commits_update_trailer() {
|
||||
let f = OrgFixture::new();
|
||||
let owner = f.owner_member_id();
|
||||
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||
f.create_collection_and_grant("prod");
|
||||
assert!(f.run(&[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "Mail", "--username", "old", "--password", "pw",
|
||||
]).status.success());
|
||||
|
||||
// Edit the username.
|
||||
let out = f.run(&[
|
||||
"org", "edit", "Mail", "--username", "new-user",
|
||||
]);
|
||||
// org edit is now interactive per-type: keep title, set username=new-user,
|
||||
// keep URL, decline password change.
|
||||
let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
|
||||
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// get --show reflects the new username.
|
||||
let out = f.run(&["org", "get", "Mail", "--show"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
||||
@@ -215,3 +244,344 @@ fn org_rm_restore_purge_cycle() {
|
||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
||||
}
|
||||
|
||||
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
|
||||
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
|
||||
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
|
||||
// must mask every secret unless `--show` is passed — asserted below.
|
||||
|
||||
#[test]
|
||||
fn org_add_card_via_stdin_then_get_masks_secret() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
|
||||
// build_card reads number, then cvv, then pin — one line each, in that order.
|
||||
let out = f.run_stdin(
|
||||
&[
|
||||
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||
],
|
||||
"4111111111111111\n123\n4321\n",
|
||||
);
|
||||
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// get masks the card number by default.
|
||||
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
|
||||
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
|
||||
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
|
||||
|
||||
// --show reveals it.
|
||||
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
|
||||
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_key_via_stdin_then_get_masks_material() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
|
||||
// build_key reads key material from stdin to EOF (multiline secret).
|
||||
let out = f.run_stdin(
|
||||
&[
|
||||
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
|
||||
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
|
||||
],
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
);
|
||||
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = f.run(&["org", "get", "Deploy Key"]);
|
||||
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
|
||||
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
|
||||
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_totp_with_secret_flag_round_trips() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
|
||||
// Totp accepts the base32 secret via --secret (no stdin needed).
|
||||
let out = f.run(&[
|
||||
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||
]);
|
||||
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = f.run(&["org", "get", "AWS root"]);
|
||||
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
|
||||
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_card_interactive_changes_holder() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let out = f.run_stdin(
|
||||
&[
|
||||
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
||||
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
||||
],
|
||||
"4111111111111111\n123\n4321\n",
|
||||
);
|
||||
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Interactive edit: keep title, set holder, decline number change.
|
||||
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
|
||||
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = f.run(&["org", "get", "Corp Visa"]);
|
||||
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
|
||||
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
|
||||
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_totp_interactive_changes_issuer() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
assert!(f.run(&[
|
||||
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
||||
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
||||
]).status.success());
|
||||
|
||||
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
|
||||
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
|
||||
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = f.run(&["org", "get", "AWS root"]);
|
||||
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
|
||||
}
|
||||
|
||||
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
|
||||
|
||||
#[test]
|
||||
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
|
||||
let f = OrgFixture::new();
|
||||
// `secret` exists but is NOT granted to the owner.
|
||||
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
|
||||
|
||||
// ensure_grant runs before any secret prompt in run_add, so these need no
|
||||
// stdin — each new type must be rejected for a collection it lacks a grant for.
|
||||
for args in [
|
||||
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
|
||||
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
|
||||
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||
] {
|
||||
let out = f.run(&args);
|
||||
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(err.contains("access denied") || err.contains("grant"),
|
||||
"expected grant denial for {args:?}: {err}");
|
||||
}
|
||||
|
||||
// …and rejected for a nonexistent collection.
|
||||
for args in [
|
||||
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
|
||||
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
|
||||
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
|
||||
] {
|
||||
let out = f.run(&args);
|
||||
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(err.contains("does not exist") || err.contains("ghost"),
|
||||
"expected unknown-collection error for {args:?}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_secure_note_via_body_stdin_masks_body() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
|
||||
let out = f.run_stdin(
|
||||
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
|
||||
"line one\nsuper-secret-line\n",
|
||||
);
|
||||
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = f.run(&["org", "get", "Runbook"]);
|
||||
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
||||
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
|
||||
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
|
||||
|
||||
let shown = f.run(&["org", "get", "Runbook", "--show"]);
|
||||
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_totp_via_secret_stdin_round_trips() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
// build_totp(secret_stdin=true) reads one base32 line from stdin.
|
||||
let out = f.run_stdin(
|
||||
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
|
||||
"JBSWY3DPEHPK3PXP\n",
|
||||
);
|
||||
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = f.run(&["org", "get", "VPN"]);
|
||||
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_key_replaces_material_and_reveals_with_show() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let out = f.run_stdin(
|
||||
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
|
||||
"--label", "ci", "--material-stdin"],
|
||||
"OLD-MATERIAL-aaaa\n",
|
||||
);
|
||||
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
|
||||
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
|
||||
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
|
||||
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let masked = f.run(&["org", "get", "Signing Key"]);
|
||||
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
|
||||
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
|
||||
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
|
||||
|
||||
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
|
||||
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
|
||||
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
|
||||
}
|
||||
|
||||
// --- v0.8.1 org Document tests -----------------------------------------------
|
||||
|
||||
/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/`
|
||||
/// proves every attachment add/remove was staged into the signed commit.
|
||||
fn git_porcelain(repo: &str) -> String {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-C", repo, "status", "--porcelain"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_document_stores_collection_scoped_attachment() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let src = srcdir.path().join("note.txt");
|
||||
std::fs::write(&src, b"secret memo").unwrap();
|
||||
|
||||
let out = f.run(&["org", "add", "document", "--collection", "eng",
|
||||
"--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Encrypted blob at attachments/eng/<item-id>/<att-id>.enc (3 segments).
|
||||
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||
assert!(att_eng.exists(), "attachment dir missing");
|
||||
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir");
|
||||
let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(blobs.len(), 1, "expected exactly one attachment blob");
|
||||
assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc");
|
||||
|
||||
let got = f.run(&["org", "get", "Q3 Memo"]);
|
||||
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||
assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}");
|
||||
// Staging proof: nothing attachment-related left uncommitted.
|
||||
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_purge_document_removes_attachment_dir() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let src = srcdir.path().join("d.bin");
|
||||
std::fs::write(&src, b"bytes").unwrap();
|
||||
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||
"--title", "Doc", "--file", src.to_str().unwrap()]).status.success());
|
||||
|
||||
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||
assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add");
|
||||
|
||||
assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm");
|
||||
let out = f.run(&["org", "purge", "Doc"]);
|
||||
assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none();
|
||||
assert!(empty, "attachment dir should be gone after purge");
|
||||
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_document_replaces_attachment_and_stages_cleanly() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let a = srcdir.path().join("a.txt");
|
||||
std::fs::write(&a, b"version A").unwrap();
|
||||
assert!(f.run(&["org", "add", "document", "--collection", "eng",
|
||||
"--title", "Spec", "--file", a.to_str().unwrap()]).status.success());
|
||||
|
||||
let b = srcdir.path().join("b.md");
|
||||
std::fs::write(&b, b"version B has different content").unwrap();
|
||||
let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string();
|
||||
assert!(got.contains("Filename: b.md"), "get should show new filename: {got}");
|
||||
assert!(!got.contains("a.txt"), "old filename should be gone: {got}");
|
||||
|
||||
// Old blob replaced, not accumulated: exactly one blob remains.
|
||||
let att_eng = f.vault_path().join("attachments").join("eng");
|
||||
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
assert_eq!(item_dirs.len(), 1, "one item attachment dir");
|
||||
let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count();
|
||||
assert_eq!(blobs, 1, "old blob must be replaced, not accumulated");
|
||||
|
||||
// The key staging proof: no orphaned old blob / unstaged new blob.
|
||||
assert!(!git_porcelain(f.vault_str()).contains("attachments/"),
|
||||
"edit-replace left attachment changes unstaged (incomplete git add)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_file_on_non_document_is_rejected() {
|
||||
let f = OrgFixture::new();
|
||||
f.create_collection_and_grant("eng");
|
||||
assert!(f.run(&["org", "add", "login", "--collection", "eng",
|
||||
"--title", "Site", "--password", "p"]).status.success());
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let x = srcdir.path().join("x.txt");
|
||||
std::fs::write(&x, b"nope").unwrap();
|
||||
|
||||
let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]);
|
||||
assert!(!out.status.success(), "--file on a Login must be rejected");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_document_into_ungranted_collection_is_denied() {
|
||||
let f = OrgFixture::new();
|
||||
// Collection exists but the owner is NOT granted.
|
||||
assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(),
|
||||
"create-collection");
|
||||
let srcdir = TempDir::new().unwrap();
|
||||
let src = srcdir.path().join("f.txt");
|
||||
std::fs::write(&src, b"data").unwrap();
|
||||
|
||||
let out = f.run(&["org", "add", "document", "--collection", "secret",
|
||||
"--title", "X", "--file", src.to_str().unwrap()]);
|
||||
assert!(!out.status.success(), "ungranted document add must fail");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||
// Grant is enforced before any attachment is written.
|
||||
assert!(!f.vault_path().join("attachments").join("secret").exists(),
|
||||
"no attachment dir should exist on a denied add");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-core"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -82,6 +82,7 @@ collections.json # collection definitions
|
||||
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||||
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||||
attachments/<collection-slug>/<item-id>/<att-id>.enc # Document attachment blobs (collection-scoped)
|
||||
```
|
||||
|
||||
### `org.json` — OrgMeta
|
||||
@@ -123,7 +124,13 @@ Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org mas
|
||||
|
||||
These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present.
|
||||
|
||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types.
|
||||
### `attachments/<collection-slug>/<item-id>/<att-id>.enc`
|
||||
|
||||
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key — the encrypted file payload of a Document item. As with item blobs, the blob does **not** name its collection; the leading `<collection-slug>` path segment carries it, so the pre-receive hook (`relicario-server`, `classify_path`) authorizes the write by slug without decrypting — reusing the same grant + slug-existence check as the `items/` branch. The path is **exactly three segments** after `attachments/` (`<collection-slug>/<item-id>/<att-id>.enc`); the hook rejects any other shape (segment-count and `.`-free slug guards). `<att-id>` is the content-addressed `AttachmentId` (see **Item IDs and Field IDs** below).
|
||||
|
||||
Per-attachment size is capped at `DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024` (10 MiB) (`org_session.rs:24`), mirroring the personal-vault default `AttachmentCaps::per_attachment_max_bytes` (`settings.rs:116`). Org vaults have no `settings.enc`, so this cap is a fixed default rather than per-org configurable. Blobs are persisted / read / removed by `UnlockedOrgVault::save_attachment` / `load_attachment` / `remove_item_attachments` (`org_session.rs:137`, `:147`, `:156`). The storage primitives back the org **Document** item type; the `org add document` / Document-edit commands that produce these blobs land in v0.8.1 (see the item-type-parity note below).
|
||||
|
||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types (landing in v0.8.1; Document file payloads use the attachment layout above).
|
||||
|
||||
## Item IDs and Field IDs
|
||||
|
||||
|
||||
128
docs/superpowers/reviews/2026-06-20-product-audit.md
Normal file
128
docs/superpowers/reviews/2026-06-20-product-audit.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Product Audit — Relicario — 2026-06-20 · fast
|
||||
|
||||
> Generated by the `product-expert` skill (roadmap audit, fast mode). Competitive
|
||||
> read grounded in `references/competitive-landscape.md` (last-reviewed 2026-06-20).
|
||||
> Advisory only — record of what was considered, not a commitment.
|
||||
|
||||
## Reality check
|
||||
|
||||
v0.8.1 tagged today: `relicario org add`/`edit` now covers **all 7 item types**
|
||||
with collection-scoped, grant-enforced attachments — sitting on the
|
||||
cryptographically serious v0.8.0 org backend (ECIES per-member key wrap,
|
||||
signature-verifying pre-receive hook). The personal vault is genuinely complete
|
||||
with full CLI↔extension parity. But the **defining reality is an asymmetry**:
|
||||
Relicario has now built an entire enterprise org vault that *cannot be touched
|
||||
from a browser* — the extension has zero org concept. The biggest recent
|
||||
investment has no GUI surface. No lift is currently active.
|
||||
|
||||
**Drift found** (low severity, but catching it is this skill's job):
|
||||
- `STATUS.md:7` — "Last release tagged: **v0.6.0**". Stale: v0.8.0 and v0.8.1 are
|
||||
both tagged (`git tag`; release commit `2fa4d68`).
|
||||
- `STATUS.md:8` + `ROADMAP.md:10` — "tag pending PM". Stale: the v0.8.1 tag is cut.
|
||||
- `docs/user_docs/` (12-page end-user guide) merged as a fast-follow *after* the
|
||||
tag — fine, just not inside the v0.8.1 tag.
|
||||
|
||||
## Assessment
|
||||
|
||||
**Strengths:** the wedge sits in a near-empty competitive cell — two factors
|
||||
*into the KDF* + self-host + **zero server metadata** + git audit log (1Password
|
||||
has the 2-factor KDF but is cloud-only; vaultwarden self-hosts but is
|
||||
single-factor KDF). Personal vault is complete. Org backend is real cryptographic
|
||||
work, now feature-broad.
|
||||
|
||||
**Gaps:** (1) the org vault is **invisible in the GUI** — extension has no org
|
||||
read or write; the whole enterprise feature is stranded behind the CLI (rated
|
||||
*critical*; traces to `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`).
|
||||
(2) Personal-side parity holes that make a "parity-is-a-design-value" product feel
|
||||
unfinished — favorites (no UI on either surface), group/tag editing only on some
|
||||
forms, and autofill matching by **exact hostname** (so `www.github.com` misses a
|
||||
login saved as `github.com`). (3) The pitch leads with steganography — the most
|
||||
friction-heavy, least load-bearing part of the wedge.
|
||||
|
||||
**Risks:** mobile absence caps total addressable market — but for Relicario's
|
||||
*self-selected* desktop/CLI audience that's a ceiling, not a bleeding wound, and
|
||||
treating it as an emergency would import mass-market logic that doesn't fit this
|
||||
product. The sharper risk is that a GUI-less org vault only ever reaches
|
||||
CLI-native shops — a fraction of the market the org spec implies — stranding the
|
||||
investment.
|
||||
|
||||
## Recommendations (leverage-ordered)
|
||||
|
||||
1. **REORDER — Put a GUI on the org vault you already built: extension org *read*
|
||||
next, then *write*.** *Why:* the v0.8.0+v0.8.1 backend is stranded without it;
|
||||
"unlock value already built" is the highest-ROI class of move; it's already
|
||||
roadmap item #1, and CLI reached all-7-type org write in v0.8.1 so the write
|
||||
path is unblocked. Outranks the command palette and personal-parity polish.
|
||||
*Impact/Effort:* H / M. *Risk:* browser GitHost has no commit-signing path, so
|
||||
write is harder than read — ship read first as its own slice. *Refinement:*
|
||||
scope to org **item usage** (read/add/edit a shared credential), NOT admin ops
|
||||
(member/key management staying CLI-only is a legitimate design choice; item
|
||||
usage being CLI-only is not).
|
||||
|
||||
2. **PIVOT (positioning) — Re-lead with the thesis, demote stego to an *option*.**
|
||||
*Why:* the most important thing the roadmap doesn't mention. A plain key file
|
||||
delivers the identical 256-bit second factor; stego's only marginal benefit is
|
||||
the niche "dead-drop on social media" story, while it carries the most unlock
|
||||
friction and a SPOF the project already had to paper over with the recovery-QR.
|
||||
The README leads with the gimmick and buries the moat. *Impact/Effort:* H / L
|
||||
(messaging; keep the feature). *Risk:* stego is the product's identity — keep
|
||||
it first-class-*optional*, don't delete it. *Adjacent thesis-level call:*
|
||||
offering a plain key file as an alternative second factor would lower
|
||||
onboarding friction for users who find "hide a secret in a JPEG" too weird — a
|
||||
real ADD candidate, not just messaging.
|
||||
|
||||
3. **ADD (cheap, high-ROI) — Autofill matches by registrable domain (eTLD+1), not
|
||||
exact hostname.** *Why:* exact-equality silently fails on the most common case
|
||||
(`www.` vs apex), making the extension feel broken; small, contained fix.
|
||||
*Impact/Effort:* M / L. *Risk:* use a public-suffix list to avoid over-matching.
|
||||
|
||||
4. **ADD — Close the personal parity holes: favorites UI + group/tag editing on
|
||||
every item-type form.** *Why:* CLI↔extension parity is a stated design value;
|
||||
family/individual users organize by exactly these. *Impact/Effort:* M / M.
|
||||
|
||||
5. **REORDER (defer) — Keep org phase-2 (SSO/LDAP, read audit, per-collection
|
||||
subkeys, HTTP plane) parked behind extension org parity.** *Why:* high-effort,
|
||||
no demand, pointless while the org feature has no GUI. *Impact/Effort:* M / H.
|
||||
|
||||
6. **CUT (future investment, not deletion) — Stop *deepening* the over-served
|
||||
areas:** no more stego-robustness work, no recovery-QR elaboration, leave
|
||||
field-history's knobs alone. Don't remove working features — just stop
|
||||
investing in them.
|
||||
|
||||
7. **Housekeeping — sync `STATUS.md` and `ROADMAP.md:10`** to reflect v0.8.1 as
|
||||
tagged. Five minutes; it's the exact drift this audit exists to catch.
|
||||
|
||||
**On mobile & v1.0:** mobile is the single biggest TAM ceiling, but a high-effort,
|
||||
post-v1.0 bet that partly contradicts the desktop/CLI shape of the product — a
|
||||
separate-product-scale investment, not the next move. Frame **v1.0 = the thesis,
|
||||
fully usable on the surfaces you already support**: extension org parity +
|
||||
personal parity holes closed + positioning sharpened. Mobile is a v1.x conversation.
|
||||
|
||||
## PM brief
|
||||
|
||||
```markdown
|
||||
## PRODUCT DIRECTIVE TO PM
|
||||
Time: 2026-06-20 (local)
|
||||
Source: /product-expert roadmap audit (fast)
|
||||
|
||||
Reality note: v0.8.1 is TAGGED (org item-type parity). The org vault backend is
|
||||
fully shipped but has ZERO extension GUI — the whole enterprise feature is
|
||||
CLI-only. STATUS.md still says "Last release tagged: v0.6.0" and "tag pending PM";
|
||||
sync those (5-min housekeeping) before anything else.
|
||||
|
||||
Roadmap changes (priority order):
|
||||
1. REORDER — extension org READ (org switch + collection-filtered browse) is the
|
||||
next slice; org WRITE follows as its own slice. Scope to item usage, not admin
|
||||
ops. This outranks the command palette and personal-parity polish.
|
||||
2. PIVOT (positioning) — re-lead messaging with "two secrets into the KDF +
|
||||
self-host + zero server metadata + git audit"; present the stego image as an
|
||||
optional second-factor flavor, not the headline. Keep the feature.
|
||||
3. ADD — autofill: match by registrable domain (eTLD+1), not exact hostname.
|
||||
4. ADD — favorites UI + group/tag editing across all item-type forms (parity).
|
||||
|
||||
Recommended next slice: extension org READ (H impact / M effort — puts a usable
|
||||
face on the backend you already paid for).
|
||||
|
||||
Out of scope / do NOT pick up: org phase-2 (SSO/LDAP, read audit, per-collection
|
||||
subkeys) until org has a GUI; further stego/recovery-QR hardening; mobile (post-v1.0).
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
# Extension ↔ CLI Parity Gap Analysis
|
||||
|
||||
- **Date:** 2026-06-20
|
||||
- **Author:** Dev-D, reconciled against an independent PM parity sweep
|
||||
- **Status:** Draft for review — **forward-planning**, NOT v0.8.1 scope
|
||||
- **Anchor commit:** `origin/main` `b09e0ce` (v0.8.0 org vault + v0.8.1 Dev-A foundation merged; Dev-B/C/D in flight)
|
||||
- **Scope note:** This plans a *future* milestone. Extension org **writes** remain explicitly out of scope for v0.8.1 per `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` (Plan B-2).
|
||||
|
||||
## Purpose
|
||||
|
||||
Survey the gap between the Relicario **CLI** (`relicario`) and the **browser extension**, classify every gap as a *real parity gap*, an *intended CLI-only* capability, or *already-planned-in-a-spec*, and produce a prioritized work list (with rough sizing) to bring the extension up to CLI parity. The driver is the project's **CLI/extension parity philosophy**: features should not ship "CLI-first, extension-later" without an explicit, recorded decision — this doc is that record for the current backlog.
|
||||
|
||||
## Method
|
||||
|
||||
Two **independent** surveys were run and then reconciled:
|
||||
|
||||
- **PM sweep** — 3 inventory agents + synthesis.
|
||||
- **Dev-D sweep** — 4 parallel readers (CLI / extension-UI / extension-SW / specs+roadmap) → synthesis → an adversarial completeness/accuracy critic, all reading from a worktree pinned at `b09e0ce`.
|
||||
|
||||
The two sweeps were deliberately blind to each other. All load-bearing claims in this document were **hand-verified against source** (greps + line reads); where the two sweeps disagree, the disagreement is flagged explicitly in §Reconciliation. Line citations are point-in-time against `b09e0ce` and may drift.
|
||||
|
||||
## Executive summary
|
||||
|
||||
Core item-CRUD parity is **excellent**. All 7 item types (Login, Secure Note, Identity, Card, Key, TOTP, Document) and the add / edit / view / list / trash / restore lifecycle are at full parity, and in several places the **extension is the richer surface** (live TOTP codes, custom fields/sections, TOTP-from-QR, password coloring, session auto-lock, autofill/capture). Where a *per-type* gap exists it is most often on the **CLI** side, not the extension's.
|
||||
|
||||
The genuine **extension-side** gaps cluster into three buckets:
|
||||
|
||||
1. **Metadata-management gaps (the headline finding):** editing **groups**, **tags**, and **filtering** is wired into only specific forms/surfaces in the extension, while the CLI offers them uniformly across all types; **favorites** has *zero* extension UI (strictly worse than the CLI). These are real, currently-shipping parity gaps on the *personal* vault.
|
||||
2. **Backend-exists-but-no-wire/UI:** attachment **removal** (`removeAttachmentsFromItem` helper exists, no `remove_attachment` router message), **per-item purge** (`purge_item` handler exists, only a bulk "empty trash" UI), and the `isInTab()` popup-mode gate that hides login/secure-note attachment editing in the popup window.
|
||||
3. **The org (enterprise) vault** — the single largest gap. The entire org feature (shipped CLI-only in v0.8.0) has **no extension presence** (no org routes, no org context). This is fully specced and explicitly deferred (Dev-D org-read / Plan B-2 org-write).
|
||||
|
||||
Plus one quality gap on the personal side surfaced by the PM sweep: **autofill hostname matching** is a naive exact-equality match.
|
||||
|
||||
Intentionally **CLI-only by design** (not gaps): real `git pull`/`push` and `.git`-history backup bundling (the extension writes straight to the host Contents API and keeps no local repo), the `imgsecret embed` recovery subcommand, recovery-QR's deliberate no-file-write contract, org **admin** (members/collections/grants/rotate/audit), and shell completions.
|
||||
|
||||
## Reconciliation with the PM sweep
|
||||
|
||||
The PM sweep concluded: extension at *near-full parity* on the personal surface, ahead in places, with the **org vault as the one material gap** and **autofill hostname matching as the only personal-side quality gap**.
|
||||
|
||||
**Agreements (both sweeps, independently):**
|
||||
- Org vault is the largest gap; it is fully specced and deferred (Dev-D read / Plan B-2 write).
|
||||
- The extension leads on live TOTP, custom fields/sections, password coloring.
|
||||
- The intended-CLI-only set: git sync/push, `.git` backup bundling, device-key deploy-key plumbing, org admin, shell completions.
|
||||
|
||||
**Dev-D refines / partially refutes the PM:** the personal surface is **not** "near-full parity with autofill as the only gap." There is a real cluster of **personal-side extension gaps** the PM sweep understated:
|
||||
- **Favorites — none in the extension** (`favorite` only round-trips through save fns; no toggle, no star in lists, no filter). The CLI is itself only add-only, so the extension is *strictly worse*. The PM hypothesis did not list this.
|
||||
- **Group editing — Login-form only** (`f-group` + `wireGroupAutocomplete` live in `login.ts` only; card/key/identity/totp/document forms pass `group` through without an input).
|
||||
- **Tag editing — Document-form only** (`f-tags` in `document.ts` only; other forms preserve-but-don't-edit).
|
||||
- **Filter — popup has no type filter** (vault-tab only) and **no tag filter** anywhere.
|
||||
- **Per-item purge** and **attachment add/remove** have working backends but no popup-reachable UI / no router wire.
|
||||
|
||||
**PM caught, Dev-D's taxonomy missed:** **autofill hostname matching.** `service-worker/vault.ts` (`findByHostname`, equality at `:344`) matches credentials by exact `icon_hint` equality (`(e.icon_hint ?? '').toLowerCase() === hostname`) — no `www.` strip, no registrable-domain (eTLD+1) match, so `www.example.com` will not match an item stored as `example.com`. Confirmed; folded in as a real LOW-MED personal-side gap. (Dev-D's capability taxonomy centered on item-CRUD/features and under-weighted the content-script autofill path — the PM sweep is the reason it appears here.)
|
||||
|
||||
**Methodology correction (a Dev-D self-sweep error, struck here):** the Dev-D extension-SW inventory referred to a `messages.ts` "that does not exist at that path." **That is false** — the file exists at `extension/src/shared/messages.ts` (227 lines): it holds the `PopupMessage` union (with `delete_item // soft-delete` at line 23), `POPUP_ONLY_TYPES` (line 168), and `CONTENT_CALLABLE_TYPES` (line 224). The inventory had merely dropped the `shared/` directory prefix. The substantive findings it supported (the unwired `searchItems`/`removeAttachmentsFromItem` helpers) are independently verified correct; the "file doesn't exist" caveat is removed from this document.
|
||||
|
||||
## Parity matrix
|
||||
|
||||
Support: **full** / **partial** / **none** / **n/a**. `gap_class`: **at-parity** · **real-gap** (extension work) · **real-gap (CLI-side)** (extension already ahead; CLI backlog) · **cli-only-by-design** · **already-planned**.
|
||||
|
||||
### Item types
|
||||
|
||||
| Capability | CLI | Ext | gap_class | Notes (evidence @ `b09e0ce`) |
|
||||
|---|---|---|---|---|
|
||||
| Login: create/view/edit | full | full | at-parity | CLI `add/get/edit login`; ext form + `add_item`/`update_item`/`get_item`. |
|
||||
| Secure Note: create/view/edit | full | full | at-parity | Both complete. |
|
||||
| Identity: create | full | full | at-parity | Both; ext also exposes `address`. |
|
||||
| Identity: view | full | full | at-parity | Both. |
|
||||
| Identity: edit | partial | full | real-gap (CLI-side) | CLI `edit_identity` omits `date_of_birth` + records no history; ext edits all. CLI backlog. |
|
||||
| Card: create/view | full | full | at-parity | Both. |
|
||||
| Card: edit | partial | full | real-gap (CLI-side) | CLI `edit_card` = holder+number only (no CVV/PIN/expiry/kind); ext edits all. CLI backlog. |
|
||||
| Key: create/view | full | full | at-parity | Both; ext takes `public_key` interactively. |
|
||||
| Key: edit | partial | full | real-gap (CLI-side) | CLI `edit_key` = key-material only (no label/algorithm/public_key); ext edits all. CLI backlog. |
|
||||
| TOTP: create | full | full | at-parity | Both; ext adds Steam Guard kind. |
|
||||
| TOTP: view | partial | full | real-gap (CLI-side) | CLI shows metadata only; ext shows live rotating code. See "TOTP live code". |
|
||||
| TOTP: edit | full | full | at-parity | Both. |
|
||||
| Document: create | full | full | at-parity | CLI encrypts file as attachment; ext `upload_attachment`. |
|
||||
| Document: view | partial | full | real-gap (CLI-side) | CLI metadata + `extract`; ext inline image preview. CLI backlog. |
|
||||
| Document: edit | none | full | real-gap (CLI-side) | CLI `edit` on Document is a no-op redirect to attach/extract; ext changes primary/supplementary files. CLI backlog. |
|
||||
|
||||
### Operations
|
||||
|
||||
| Capability | CLI | Ext | gap_class | Notes |
|
||||
|---|---|---|---|---|
|
||||
| add / edit / get / list | full | full | at-parity | All 7 types both surfaces. |
|
||||
| rm / soft-delete | full | full | at-parity | CLI `rm`; ext `delete_item` (`messages.ts:23`, handler `popup-only.ts`). |
|
||||
| trash (list) | full | full | at-parity | CLI `trash`; ext trash view. |
|
||||
| restore from trash | full | full | at-parity | CLI `restore`; ext `restore_item`. |
|
||||
| purge (permanent) | full | partial | **real-gap** | Ext UI only bulk "empty trash" (`purge_all_trash`, `popup-only.ts:420`); **no per-item purge UI**, though `purge_item` handler exists (`popup-only.ts:409`). CLI has single + bulk. |
|
||||
|
||||
### Features
|
||||
|
||||
| Capability | CLI | Ext | gap_class | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Attachments: add | full | partial | **real-gap** | Login/secure-note attachment editing gated behind `isInTab()` (`login.ts:370,388`; `secure-note.ts:123,140`) — unavailable in popup window; document renders unconditionally (`document.ts`). SW `upload_attachment` is full. |
|
||||
| Attachments: view/download | full | full | at-parity | CLI `extract`; ext download + `download_attachment`. |
|
||||
| Attachments: remove | full | partial | **real-gap** | SW helper `removeAttachmentsFromItem` (`vault.ts:492`) has **no router wire** (`remove_attachment` absent — confirmed). UI removes refs at form-save only, with the same `isInTab()` caveat. CLI `detach` is full. |
|
||||
| TOTP: live code | none | full | real-gap (CLI-side) | CLI reveals raw base32 only; ext computes live codes. Extension leads. No spec mandates CLI OTP. |
|
||||
| Generator: password / passphrase | full | full | at-parity | CLI `generate`; ext generator-panel + `generate_password`. |
|
||||
| Settings: view / edit | full | full | at-parity | CLI `settings`; ext `get/set_vault_settings`. |
|
||||
| Search | partial | partial | at-parity | CLI: title-substring. Ext: client-side over title/group/tags/icon_hint; SW `searchItems` (`vault.ts:316`) exists but **unwired** (no `search_items` message). Neither does field-value full-text. |
|
||||
| Filter | full | partial | **real-gap** | CLI `list` filters type/group/tag/trashed. Ext: type filter is **vault-tab-only**; popup has none; **no tag filter anywhere**. SW `list_items` filters by `group` only. |
|
||||
| Favorites | partial | **none** | **real-gap** | CLI add-only (`--favorite` on Login add, `*` in list; no toggle/filter). Ext: **zero UI** — `favorite` only round-trips. Ext strictly worse; needs a paired CLI+ext design. |
|
||||
| Tags | full | partial | **real-gap** | CLI full create+filter all types. Ext: only Document form edits tags (`f-tags` in `document.ts`); no tag chips in lists; no tag filter. SW round-trips tags. |
|
||||
| Groups/folders | full | partial | **real-gap** | CLI all types `--group`, `list --group`. Ext: only Login form has `f-group`+autocomplete; other forms set no group; vault-tab "group" filter is actually a type filter. SW `list_groups`/group-filter full. |
|
||||
| Field history (view) | full | full | at-parity | CLI `history`; ext `get_field_history`. |
|
||||
| Custom fields / sections | none | full | real-gap (CLI-side) | CLI has no custom-field/section commands (core supports them); ext `renderSectionsEditor` covers all 7 types. CLI backlog. |
|
||||
| Autofill hostname matching | n/a | partial | **real-gap** | Ext-only feature; matcher (`vault.ts` `findByHostname`, `:344`) is exact `icon_hint` equality — no `www.` strip / eTLD+1. `www.x.com` ≠ `x.com`. (PM-surfaced.) |
|
||||
|
||||
### Org (enterprise) vault
|
||||
|
||||
| Capability | CLI | Ext | gap_class | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Org: read items | full | none | already-planned | CLI `org get/list` grant-filtered, all 7 types. Ext has zero org code. Planned Dev-D. Spec: `2026-06-06-relicario-enterprise-org-vault-design.md` § Extension — Org Context; ROADMAP "Extension org parity — read". |
|
||||
| Org: write (add/edit/rm) | partial | none | already-planned | CLI write = Login/SecureNote/Identity only (`OrgAddKind`, `main.rs:560`; Card/Key/Document/Totp absent — v0.8.1 lift in flight). Ext none. Planned Plan B-2. Spec: `2026-06-20-relicario-v0.8.1-parity.md` § Out of scope. |
|
||||
| Org: member/collection mgmt | full | none | cli-only-by-design | CLI full lifecycle (~19 subcommands). Ext none — org **admin** is intended CLI-only (high-trust, low-frequency). |
|
||||
|
||||
### Vault lifecycle / infra
|
||||
|
||||
| Capability | CLI | Ext | gap_class | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Vault init / setup | full | full | at-parity | CLI `init`; ext setup wizard + `create_vault`/`attach_vault`. |
|
||||
| Git sync (pull/push) | full | partial | cli-only-by-design | CLI real `git pull --rebase`/`push`. Ext writes straight to host Contents API; no local graph (`ahead`/`behind` always 0). Functionally syncs; architecturally different by design (`extension/ARCHITECTURE.md`). |
|
||||
| Device management | full | full | at-parity | CLI `device`; ext `renderDevices` + SW device CRUD. (GitHub/GitLab deploy-key API is the deferred edge.) |
|
||||
| Backup / restore | full | full | at-parity | CLI `.relbak` + git-history bundling; ext `export/restore_backup`. `.git` bundling sub-aspect is cli-only-by-design (ext has no local repo). |
|
||||
| Import (LastPass) | partial | partial | at-parity | Both LastPass-CSV only; other importers deferred both surfaces by policy. |
|
||||
| Recovery QR | full | full | at-parity | CLI generate/unwrap; ext `generate/unwrap_recovery_qr`. Webcam scan deferred both. |
|
||||
| Standalone generate (no vault) | full | none | cli-only-by-design (low-confidence) | CLI `generate`/`rate` work outside a vault; ext generator is embedded in login form + settings (needs unlocked vault). A browser extension lacks the "no vault" generator use-case a shell has. No spec; flag if user demand appears. |
|
||||
|
||||
### Intended CLI-only (no taxonomy row; recorded so they are not re-litigated as gaps)
|
||||
|
||||
| Capability | gap_class | Notes / spec |
|
||||
|---|---|---|
|
||||
| Recovery-QR file-write (`--out`) | cli-only-by-design | Negative API contract — no surface writes the payload to disk; absence *is* the security property. `2026-05-01-recovery-qr-design.md`. |
|
||||
| Org delete-org push to remote | cli-only-by-design | Phase-1 delete-org is a local tombstone; pre-receive hook rejects protected-file deletion. Pushable delete-org is phase-2. `2026-06-06-...-design.md`. |
|
||||
| `imgsecret embed` subcommand | cli-only-by-design | CLI disaster-recovery tool; the extension setup wizard's image flow covers the equivalent. |
|
||||
| Password coloring (CLI TTY) | cli-only-by-design (inverted) | Ext shipped it (v0.5.1); CLI TTY parity deferred until demand. `2026-05-01-password-coloring-design.md` § Out of scope. |
|
||||
| Shell completions | cli-only-by-design | No extension analogue. |
|
||||
|
||||
## Gap classification summary
|
||||
|
||||
- **Real extension gaps (extension work closes them):** per-item purge UI; attachment add/remove UI + `remove_attachment` wire + `isInTab()` gate; popup type filter + tag filter; tag editing on all forms; group editing on all forms; favorites UI; autofill registrable-domain matching; **org read** (specced); **org write** (specced, behind CLI type parity).
|
||||
- **CLI-side gaps (extension already ahead — separate CLI backlog, NOT extension work):** Identity/Card/Key edit field coverage; Document view/edit; live TOTP code; custom fields/sections commands.
|
||||
- **Intended CLI-only (not gaps):** git pull/push, `.git` backup bundling, org admin, `imgsecret embed`, recovery-QR file-write, shell completions, standalone generate.
|
||||
- **Already planned / deferred:** org read (Dev-D), org write + org item-type breadth (Plan B-2), org attachments/multi-vault (behind org).
|
||||
|
||||
## Prioritized forward work (extension)
|
||||
|
||||
Only items where **extension work** closes the gap. CLI-side gaps and intended-CLI-only items are excluded.
|
||||
|
||||
| Pri | Item | Size | Why | Depends on |
|
||||
|---|---|---|---|---|
|
||||
| **P0** | **Attachment remove + un-gate popup:** wire a `remove_attachment` router message to `removeAttachmentsFromItem` (`vault.ts:492`); drop the `isInTab()` gate so login/secure-note attachment **add & remove** work in the popup. Closes *both* the add half (row "Attachments: add") and remove half. | M | Backend exists and is unreachable — highest value-per-effort. The popup-mode gate is a UX cliff (can't manage login attachments without popping out). | — |
|
||||
| **P0** | **Per-item purge UI:** surface the existing `purge_item` handler (`popup-only.ts:409`) as a per-row permanent-delete in the trash view (today only bulk `purge_all_trash`). | S | Pure UI wiring over an existing handler; CLI has single-item purge. | — |
|
||||
| **P1** | **Group editing on all type forms:** add `f-group` + `wireGroupAutocomplete` to card/key/identity/totp/document (Login-only today). | M | SW (`list_groups`, group filter) already full; replicate one existing form pattern across 5 forms. | — |
|
||||
| **P1** | **Tag editing on all type forms + tag chips in lists:** promote Document's `f-tags` to a shared affordance on all 7 forms. | M | SW round-trips tags fully; only Document edits them today. | — |
|
||||
| **P1** | **Filter parity:** add a type filter to the popup (vault-tab has it) and a tag filter to popup + vault tab; optionally push type/tag params into `list_items`. | M | CLI filters type/group/tag; ext type filter is fullscreen-only, tag filter absent. | Tag editing (so tags exist to filter on) |
|
||||
| **P2** | **Favorites (paired CLI + ext):** favorite toggle in detail/edit, favorites filter, star in list rows — and extend the CLI beyond add-only, to reach *true* parity per the parity philosophy. | M | Ext strictly worse than CLI (none vs partial); both surfaces weak. Write a short spec first. | — (spec TBD) |
|
||||
| **P2** | **Autofill registrable-domain matching:** replace exact `icon_hint` equality (`vault.ts` `findByHostname:344`) with `www.`-strip + eTLD+1 matching. | S–M | `www.x.com` ≠ `x.com` today; the one personal-side quality gap. | — |
|
||||
| **P2** | **Search wire-up (hardening):** expose `searchItems` (`vault.ts:316`) via a `search_items` message, or formally adopt client-side filtering and remove the dangling helper. | S | Functionally at-parity, but an unwired helper is dead-code drift. | — |
|
||||
| **P3** | **Org read in extension (Dev-D):** org context switcher + SW org handlers (unwrap org master key into a `Zeroizing` session handle) + grant-filtered manifest browse/read in popup + vault tab. | XL | Largest single gap; entire org feature is CLI-only in the extension. Specced, deferred. | — |
|
||||
| **P3** | **Org offline read-only indicator:** "org offline — writes disabled" banner when the git remote is unreachable in org context. | S | Spec-mandated UX. | Org read |
|
||||
| **P3** | **Org SW acceptance tests:** org context replaces personal manifest cleanly; org master key never in localStorage/IndexedDB; offline mode triggers on network error. | M | Spec-mandated coverage following the feature. | Org read |
|
||||
| **P3** | **Org write in extension (Plan B-2):** org add/edit/rm including Card/Key/Document/Totp. | XL | Closes org write + item breadth. Deferred past v0.8.1. | Org read **and** CLI reaching Card/Key/Document/Totp org-write parity (v0.8.1) |
|
||||
|
||||
## Caveats
|
||||
|
||||
- Line citations are point-in-time against `b09e0ce` and drift with edits.
|
||||
- This is a planning artifact, not a commitment; sizes are rough (S ≈ hours, M ≈ a day, L ≈ days, XL ≈ a multi-stream lift).
|
||||
- Two analytical errors caught during cross-check and corrected here: (1) the struck `messages.ts`-doesn't-exist claim (file exists at `extension/src/shared/messages.ts`); (2) a few inventory line numbers were off by single digits and have been replaced with hand-verified ones.
|
||||
104
docs/superpowers/specs/2026-06-20-extension-org-gui-design.md
Normal file
104
docs/superpowers/specs/2026-06-20-extension-org-gui-design.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Extension Org Vault GUI — Design Spec
|
||||
|
||||
- **Date:** 2026-06-20
|
||||
- **Status:** Approved (brainstorming) — ready for writing-plans
|
||||
- **Release target:** v0.9.0 (one multi-agent train, alongside the Pluggable Second Factor spec)
|
||||
- **Anchor:** `main` post-v0.8.1 (`2fa4d68` tag; HEAD `59ebc28`)
|
||||
- **Driver:** Product audit `docs/superpowers/reviews/2026-06-20-product-audit.md` recommendation #1 — the org vault backend (v0.8.0 + v0.8.1) is fully shipped but has **zero extension presence**; the enterprise feature is stranded behind the CLI.
|
||||
- **Builds on:** `2026-06-06-relicario-enterprise-org-vault-design.md` (§ Extension — Org Context), `2026-06-20-extension-cli-parity-gap-analysis.md` (P3 cluster), `extension/ARCHITECTURE.md`.
|
||||
|
||||
## Purpose & scope
|
||||
|
||||
Bring the org (enterprise) vault to the browser extension at **read + write** parity, so org members can browse, view, add, edit, and delete shared credentials from the popup and vault tab — not only the CLI. Org **admin** operations (member/collection/grant/rotate/audit) stay CLI-only by design (high-trust, low-frequency; org spec § Extension scopes them out).
|
||||
|
||||
**In scope:** org context switching; grant-filtered browse/read of org items (all 7 types) in popup + vault tab; org item write (add/edit/rm, all 7 types); offline read-only indicator; SW acceptance tests.
|
||||
|
||||
**Out of scope:** org admin in the extension; per-collection cryptographic isolation, SSO/LDAP, read audit, HTTP plane (all org phase-2); webcam recovery scan.
|
||||
|
||||
## The org-write signing gate (highest risk — read first)
|
||||
|
||||
The org pre-receive hook **rejects unsigned commits unconditionally**: it shells `git verify-commit` and requires a device ed25519/SSH signature from a current member (`crates/relicario-server/src/main.rs:95-102`). The extension today pushes **unsigned** commits via the host Contents API (`extension/src/service-worker/gitea.ts` → `/api/v1/.../contents/{path}`), authored under the API token. The ed25519 signing primitive **exists in WASM** (`sign_for_git`, `crates/relicario-wasm/src/lib.rs:253`) but is **unused** by the extension — there is no signed-commit push path, and the Contents API cannot carry a caller-supplied signature.
|
||||
|
||||
Therefore org **write** from the extension must construct and push a **signed commit via the Git Data API** (blob → tree → commit-with-signature → update-ref). This is feasible but unproven against the host APIs.
|
||||
|
||||
**Stream A3 begins with a spike, gating the rest of A3:**
|
||||
|
||||
> Prove that a commit signed in the SW with `sign_for_git`, pushed via the **Gitea** Git Data API, passes server-side `git verify-commit` **and** `relicario-server verify-org-commit`. Then repeat for **GitHub**.
|
||||
|
||||
- **Spike passes** → A3 proceeds (signed-push GitHost path + org write UI); v0.9.0 ships read + write.
|
||||
- **Spike fails** (host API strips/normalizes the SSH signature such that `git verify-commit` fails) → org write degrades to a **follow-up lift**; v0.9.0 still ships **org read (A0–A2, A4-read)** + the full Pluggable Second Factor spec. The spike is ~1 day and read + the other spec are unblocked regardless, so a failed spike wastes nothing.
|
||||
|
||||
The spike result is recorded back into this spec and `STATUS.md` before A3 build work starts.
|
||||
|
||||
## Architecture
|
||||
|
||||
An org vault is a second git repo alongside the personal vault, cryptographically isolated (org spec § Architecture). The org master key is a random 256-bit key, wrapped per member via ECIES (X25519 + XChaCha20-Poly1305) to their device ed25519 key. The extension mirrors the CLI: unwrap the org key with the device private key, decrypt items exactly as the personal vault does — but with the org key, not the Argon2id-derived personal key.
|
||||
|
||||
The extension keeps its existing architecture (SW is the crypto fortress; popup/vault are StateHost-driven view shells over `chrome.runtime.sendMessage`). Org support is an additive context, not a rewrite.
|
||||
|
||||
## Stream decomposition
|
||||
|
||||
### A0 · WASM org bridge (prerequisite)
|
||||
|
||||
`relicario-core::org` already performs ECIES unwrap and org item crypto for the CLI; none of it is exposed over `relicario-wasm` today (confirmed — no org exports in `crates/relicario-wasm/src/lib.rs`). Expose:
|
||||
|
||||
- `org_unwrap_key(keys_blob: &[u8], device_private_openssh: &str) -> OrgHandle` — unwrap `keys/<member-id>.enc` into the org master key held in WASM `Zeroizing`, returning an opaque slot handle in the same pattern as the personal `SessionHandle` (the key never crosses to JS).
|
||||
- `org_item_encrypt/decrypt(OrgHandle, …)` and `org_manifest_encrypt/decrypt(OrgHandle, …)` — XChaCha20-Poly1305 with the org key directly (no Argon2id), reusing core.
|
||||
- `org_handle_free(OrgHandle)` — zero the slot.
|
||||
|
||||
Everything else depends on A0.
|
||||
|
||||
### A1 · SW org foundation
|
||||
|
||||
- **Multi-context session.** `extension/src/service-worker/session.ts` is a single module-scope `SessionHandle | null` ("one vault per install"). Replace with a context map — `{ personal: Handle | null, orgs: Map<orgId, OrgHandle> }` plus a current-context pointer. The inactivity timer and `lock` zero **every** handle. Org handles are never written to `localStorage`, `IndexedDB`, or any persistent store (org spec line 231).
|
||||
- **Org config storage.** `chrome.storage.local.orgConfigs: Array<{ orgId, displayName, hostType, hostUrl, repoPath, apiToken, memberId }>` — mirrors the personal `vaultConfig`. (The device key in `chrome.storage.local.device_private_key` is reused to unwrap org keys; no new device identity.)
|
||||
- **Org GitHost.** One `GitHost` per org repo via the existing `createGitHost` factory.
|
||||
- **Read flow.** On switch to an org: read public `members.json` + `collections.json` (unencrypted) → locate this device's member record by ed25519 fingerprint → take its `collections` grant list → `org_unwrap_key` → fetch + `org_manifest_decrypt` → **filter manifest entries to granted collection slugs** → cache. Items decrypt on demand via `org_item_decrypt`.
|
||||
- **Offline.** If the org `GitHost` fetch throws a network error, serve the last-pulled manifest read-only and set an `orgOffline` flag.
|
||||
- **New SW messages (popup-only):** `org_list_configs`, `org_switch { context: 'personal' | orgId }`, `org_list_items` (grant-filtered), `org_get_item`, `org_list_collections`. Each must be added to the `PopupMessage` union AND `POPUP_ONLY_TYPES` AND a handler arm (`extension/src/shared/messages.ts` — the three-place rule).
|
||||
|
||||
### A2 · Org read UI
|
||||
|
||||
- **Context switcher.** A top-level Personal / `<org>`… selector in the vault-tab sidebar (primary surface per org spec) and the popup header. Switching sends `org_switch` and reloads the list.
|
||||
- **Reuse.** The `popup/components/*` renderers are `StateHost`-driven, so org item detail/type views render unchanged once the host projects org state (items, collections). A collection facet in the sidebar mirrors the existing type-category nav.
|
||||
- **Offline indicator.** "org offline — writes disabled" banner when `orgOffline`.
|
||||
|
||||
### A3 · Org write (gated on the signing spike)
|
||||
|
||||
- **Signed-push GitHost path.** A new method that builds a commit through the Git Data API, signs the commit object with `sign_for_git` (device key from `chrome.storage.local`), pushes it, and updates the ref. (Generic enough that personal device-auth writes could later adopt it.)
|
||||
- **SW write handlers.** `org_add_item`, `org_update_item`, `org_delete_item`: encrypt with the org handle, write to the **collection-scoped** path `items/<slug>/<id>.enc`, update the org manifest — **both writes** via signed push (the personal "manifest + item both written" invariant applies, `extension/ARCHITECTURE.md` § Invariants).
|
||||
- **UI.** Add/edit reuse the existing per-type item forms; add gains a **granted-collection picker**. Delete = soft-delete (trash) in the org manifest, mirroring personal trash semantics — the org CLI already ships `rm`/`restore`/`purge` (v0.8.0), so the backend is ready.
|
||||
|
||||
### A4 · Org SW acceptance tests (vitest)
|
||||
|
||||
Per org spec § Extension Tests, plus write coverage: org context switch replaces the personal manifest with no cross-contamination; org master key appears only in the `Zeroizing` session, never in `localStorage`/`IndexedDB`; offline read-only triggers on a git network error; grant filtering hides ungranted collections; a write produces a signed commit the hook accepts (mock the hook contract).
|
||||
|
||||
## Data flow (read)
|
||||
|
||||
```
|
||||
popup/vault → org_switch(orgId) → SW:
|
||||
read members.json + collections.json (public)
|
||||
→ match device fingerprint → grants
|
||||
→ org_unwrap_key(keys/<member-id>.enc, device_priv) → OrgHandle (Zeroizing, in WASM)
|
||||
→ fetch manifest.enc → org_manifest_decrypt → filter to grants → cache
|
||||
popup/vault → org_list_items → SW returns grant-filtered projection (titles/collections, no secrets)
|
||||
popup/vault → org_get_item(id) → SW org_item_decrypt → resolved item
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Device not a member** (fingerprint absent from `members.json`) → `not_an_org_member` — clear "this device isn't a member of <org>".
|
||||
- **Ungranted collection** → filtered out on read; rejected client-side on write before push (and by the hook as defense in depth).
|
||||
- **Offline** → read-only banner; writes blocked client-side with the indicator.
|
||||
- **Signed push / hook rejection** → surfaced verbatim; the manifest write is not attempted if the item write fails (no half-mutation).
|
||||
- Reuse the existing snake_case SW error convention + `humanizeError`.
|
||||
|
||||
## Living-docs impact
|
||||
|
||||
`extension/ARCHITECTURE.md` (org context, multi-context session, signed-push path, new messages), `docs/SECURITY.md` (extension org key handling + signed-commit write path), `ROADMAP.md`/`STATUS.md` (org parity shipped), `CHANGELOG.md`. The org spec's § Extension scope note (read-only phase-1) is superseded by this spec's read+write decision — note that in the org spec.
|
||||
|
||||
## Open risks
|
||||
|
||||
1. **Org-write signing spike** (§ above) — the gating unknown.
|
||||
2. **Multi-context session refactor** touches the SW's most security-sensitive module (`session.ts`); the lock/timer-zeroes-all invariant must be preserved and tested.
|
||||
3. **Git Data API divergence** Gitea vs GitHub for signed commits — the spike must cover both; if only one host works, ship org write for that host and record the limitation.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Pluggable Second Factor (Key File) + Positioning Pivot — Design Spec
|
||||
|
||||
- **Date:** 2026-06-20
|
||||
- **Status:** Approved (brainstorming) — ready for writing-plans
|
||||
- **Release target:** v0.9.0 (one multi-agent train, alongside the Extension Org Vault GUI spec)
|
||||
- **Anchor:** `main` post-v0.8.1 (`2fa4d68` tag; HEAD `59ebc28`)
|
||||
- **Driver:** Product audit `docs/superpowers/reviews/2026-06-20-product-audit.md` recommendation #2 (PIVOT) — re-lead positioning with the durable thesis (two secrets into the KDF + zero server metadata + git audit) and treat the steganographic image as **one option** for the second factor, with a plain key file as the alternative.
|
||||
- **Builds on:** `2026-04-11-relicario-design.md` (crypto pipeline), `docs/CRYPTO.md`, `docs/FORMATS.md`, `extension/ARCHITECTURE.md` (setup wizard).
|
||||
|
||||
## Purpose & scope
|
||||
|
||||
Make the vault's second factor **pluggable**: the 256-bit secret can be carried by the existing steganographic reference image (default) **or** by a plain **key file** — chosen at vault creation, with the same secret and the same KDF underneath. Re-lead the project's positioning on the durable thesis and frame stego as an option rather than the headline.
|
||||
|
||||
**Key insight — this is crypto-light.** The second factor is *already* just 32 bytes (`image_secret`); stego is only the storage/transport. The Argon2id KDF (`passphrase || image_secret → master_key`) and everything downstream are **byte-for-byte unchanged**. Only the *source* of the 32 bytes changes. No new crypto primitive.
|
||||
|
||||
**Mental model (chosen in brainstorming):** at creation you pick the container — **Reference Image** (default) or **Key File** — and both materialize the same random 32-byte secret. The vault records a non-secret container-type hint so unlock prompts for the right thing. Because it is literally the same secret, the recovery-QR already fits this model, and export/convert between containers is a natural (optional) add-on.
|
||||
|
||||
**In scope:** key-file generation at init; unlock from a key file; the non-secret container hint; CLI + extension support; the positioning/docs pivot.
|
||||
|
||||
**Out of scope (optional stretch, not core):** `keyfile export` / convert-an-existing-image-vault-to-keyfile. It needs the secret in hand (a re-provide-then-write flow), so it is deferred to keep the lift tight; noted as a fast-follow.
|
||||
|
||||
## Crypto model
|
||||
|
||||
- **Container hint.** `.relicario/params.json` (non-secret, already holds Argon2id params) gains `"second_factor": "image" | "keyfile"`. **Absent ⇒ `"image"`** (back-compat for every existing vault). Read pre-unlock to choose the prompt; reveals nothing secret (container type is not a secret).
|
||||
- **Raw-secret unlock path.** Today `unlock(passphrase, jpeg_bytes, salt, params)` extracts the 32-byte secret from the JPEG internally (`extension/ARCHITECTURE.md` notes "unlock takes JPEG bytes … extracts internally"). Add an explicit `unlock_with_secret(passphrase, secret: &[u8;32], salt, params)` that skips extraction. The KDF and AEAD are identical; this is the only core seam.
|
||||
- **Key-file armor.** Core owns the format so CLI and WASM share it: `keyfile_encode(secret) -> Vec<u8>` and `keyfile_decode(bytes) -> [u8;32]`. Layout: a `relicario-keyfile-v1` header line + base64 of the 32 bytes + trailing newline. `keyfile_decode` validates the header, rejects malformed input, and holds the secret in `Zeroizing`. Suggested extension: `.relkey`.
|
||||
|
||||
## Stream decomposition
|
||||
|
||||
### B1 · core + WASM
|
||||
|
||||
- `relicario-core`: `keyfile_encode` / `keyfile_decode` (Zeroizing), `unlock_with_secret`, and read/write of the `second_factor` field in the params struct (default `image`).
|
||||
- `relicario-wasm`: bind `keyfile_encode`, `keyfile_decode`, `unlock_with_secret`.
|
||||
- Equivalence test: for a given 32-byte secret, `unlock_with_secret(pass, secret, …)` derives the **same** master key as unlocking from a JPEG that embeds that secret — proves the seam is transport-only.
|
||||
|
||||
### B2 · CLI
|
||||
|
||||
- `relicario init`: a container choice — `--key-file <path>` (or interactive) generates the 32-byte secret, writes the `.relkey` via `keyfile_encode`, and sets params `second_factor: "keyfile"`; the existing `--image`/`--output` path stays the default and sets `"image"`.
|
||||
- `unlock` across all commands: read the factor per the params hint — if `keyfile`, from `--key-file` or `RELICARIO_KEYFILE` (mirroring `RELICARIO_IMAGE`); `keyfile_decode` → `unlock_with_secret`.
|
||||
- Help text + `docs/user_docs/` reflect the choice.
|
||||
|
||||
### B3 · extension
|
||||
|
||||
- **Setup wizard step 3** gains a container choice (Reference Image | Key File). Key-file mode: generate the secret, offer the `.relkey` for download, set the params hint.
|
||||
- **Unlock**: per the params hint, prompt for the key file (file picker) instead of the image; `keyfile_decode` → `unlock_with_secret`.
|
||||
- **Local storage (chosen default):** store the key-file bytes in `chrome.storage.local` as `keyfileBase64`, re-read each unlock — exactly as `imageBase64` works today. Same "something you have" threat model and the same offline behavior; documented as equivalent, not weaker.
|
||||
|
||||
### B4 · docs / positioning pivot
|
||||
|
||||
- **README** re-led: open with the thesis (two independent secrets into the KDF, self-host, zero server metadata, git audit); present the steganographic image as a distinctive **option** for the second factor (with the key file as the plain alternative), not the headline. Keep the dead-drop story as flavor, not the lead.
|
||||
- **DESIGN.md** secrets-map + **docs/CRYPTO.md** (pluggable-transport framing: "the second factor is 32 bytes; image/key-file/recovery-QR are interchangeable containers") + **docs/FORMATS.md** (`.relkey` armor + params `second_factor` field).
|
||||
|
||||
## Security-review gate (before merge)
|
||||
|
||||
A focused `/security-review` pass on the key-file path:
|
||||
|
||||
- **No weaker than stego:** same 32-byte entropy, same Argon2id, same AEAD. The equivalence test (B1) is the evidence.
|
||||
- **Armor parsing** rejects malformed/short input without panics or oracles.
|
||||
- **Threat-model honesty:** `.relkey` and `keyfileBase64` are the second factor *in the clear* — exactly the same posture as the reference JPEG / `imageBase64` today. Document this in `docs/SECURITY.md`; do not imply the key file is encrypted (it is the "something you have", protected by needing the passphrase too).
|
||||
- **No oracle differences** between the image and key-file unlock failure paths (both surface the deliberately-ambiguous "wrong passphrase or reference image/key").
|
||||
|
||||
## Error handling
|
||||
|
||||
- Malformed/empty key file → `invalid_key_file` (CLI + extension), distinct from a wrong-secret AEAD failure.
|
||||
- Missing key file at unlock when params say `keyfile` → prompt/`RELICARIO_KEYFILE` guidance.
|
||||
- Params `second_factor` present but unknown value → reject with a clear message (forward-compat guard).
|
||||
|
||||
## Testing
|
||||
|
||||
- **core:** keyfile encode/decode round-trip; `unlock_with_secret` master-key equivalence vs JPEG unlock; params back-compat (absent ⇒ image); malformed-armor rejection.
|
||||
- **CLI:** `init --key-file → unlock --key-file` lifecycle against a temp vault; `RELICARIO_KEYFILE` env path; image vault still unlocks unchanged.
|
||||
- **extension (vitest):** setup key-file path writes the hint + offers download; unlock reads `keyfileBase64` and derives a session; an existing image vault is unaffected.
|
||||
|
||||
## Living-docs impact
|
||||
|
||||
`README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md`, `crates/relicario-core/ARCHITECTURE.md` (new core functions), `extension/ARCHITECTURE.md` (setup container choice + `keyfileBase64`), `CHANGELOG.md`.
|
||||
51
user_docs/README.md
Normal file
51
user_docs/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Relicario — User Guide
|
||||
|
||||
Welcome! Relicario is a password manager that keeps your logins, cards, notes, and files
|
||||
safe — and keeps **you** in control of where they live. This guide is for everyday users:
|
||||
no programming or cryptography knowledge required.
|
||||
|
||||
> **The one-minute version.** Your vault lives in a git repository *you* control (your own
|
||||
> Gitea or GitHub). The server only ever sees scrambled, unreadable data. To unlock your
|
||||
> vault you need **two things together**: a **passphrase** you remember, and a **reference
|
||||
> photo** (an ordinary JPEG you chose, with a secret hidden inside it). Neither one alone can
|
||||
> open the vault — that's what keeps your secrets yours.
|
||||
|
||||
## Start here
|
||||
|
||||
New to Relicario? Read these first, in order:
|
||||
|
||||
1. **[Getting started](getting-started.md)** — install it, create your first vault, and add your first login (about 10 minutes).
|
||||
2. **[How Relicario works](concepts.md)** — the two-factor idea, where your data is stored, and the one golden rule.
|
||||
3. **[Items](items.md)** — the seven kinds of things you can store, and how to add, view, edit, and delete them.
|
||||
|
||||
## Everyday tasks
|
||||
|
||||
- **[Passwords & generators](passwords-and-generators.md)** — create strong passwords and passphrases, and check how strong they are.
|
||||
- **[Two-factor codes (TOTP)](totp.md)** — store the rotating 6-digit codes for sites that use authenticator apps.
|
||||
- **[Attachments & documents](attachments-and-documents.md)** — keep files and scanned documents in your vault.
|
||||
- **[Organizing your vault](organizing.md)** — groups, tags, favorites, and search.
|
||||
- **[Sync & backup](sync-and-backup.md)** — use your vault on more than one computer, and back it up safely.
|
||||
- **[The browser extension](the-browser-extension.md)** — unlock, search, and autofill logins right in Chrome or Firefox.
|
||||
|
||||
## Before you need it
|
||||
|
||||
- **[Recovery — and its limits](recovery.md)** — how to protect against losing access, and the honest truth about what *cannot* be recovered. **Please read this one early, not after something goes wrong.**
|
||||
- **[FAQ](faq.md)** — quick answers to common questions.
|
||||
|
||||
## For the technically curious
|
||||
|
||||
This guide deliberately skips the engineering details. If you want to understand how the
|
||||
encryption, the hidden-image secret, or the data formats actually work, see the technical
|
||||
documentation in the repository:
|
||||
|
||||
- [`../DESIGN.md`](../DESIGN.md) — the big picture across all the pieces.
|
||||
- [`../docs/CRYPTO.md`](../docs/CRYPTO.md) — how the two factors become a key, and how data is encrypted.
|
||||
- [`../docs/SECURITY.md`](../docs/SECURITY.md) — the threat model: what Relicario protects against, and what it doesn't.
|
||||
- [`../docs/FORMATS.md`](../docs/FORMATS.md) — the on-disk and wire formats.
|
||||
|
||||
Relicario is free and open source under the GPL-3.0-or-later license — you're welcome to read,
|
||||
build, and verify every line.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Getting started](getting-started.md)
|
||||
154
user_docs/attachments-and-documents.md
Normal file
154
user_docs/attachments-and-documents.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Files: documents & attachments
|
||||
|
||||
This page covers two related ideas: storing a file as its own vault item (a **Document**), and attaching extra files to any existing item — like pinning a recovery-codes PDF to a login.
|
||||
|
||||
---
|
||||
|
||||
## Documents vs. attachments — what's the difference?
|
||||
|
||||
| | What it is | How you create it |
|
||||
|---|---|---|
|
||||
| **Document item** | A file that *is* the item — a passport scan, a signed contract, a certificate. | `relicario add document` |
|
||||
| **Attachment** | A file pinned to an existing item — a recovery-codes PDF on a login, a photo on an identity. | `relicario attach` |
|
||||
|
||||
Both are encrypted the same way. The git server only ever sees ciphertext — your file bytes never leave your device in plaintext.
|
||||
|
||||
---
|
||||
|
||||
## Storing a file as a Document item
|
||||
|
||||
A Document item holds exactly one file, encrypted, as part of the vault.
|
||||
|
||||
```
|
||||
relicario add document --file <FILE> --title "My Title"
|
||||
```
|
||||
|
||||
`--file` is required. Any fields you leave out are prompted for interactively. You can also set `--group` and `--tags` (comma-separated):
|
||||
|
||||
```
|
||||
relicario add document --file passport-scan.pdf --title "Passport scan" --group identity --tags travel,official
|
||||
```
|
||||
|
||||
Once stored, use `relicario get <QUERY>` to look it up and `relicario extract` (see below) to pull the file back out.
|
||||
|
||||
---
|
||||
|
||||
## Attaching a file to any item
|
||||
|
||||
You can pin one or more files to a login, secure note, identity, card, TOTP item, or Document. The item stays intact; the file is stored alongside it as an attachment.
|
||||
|
||||
```
|
||||
relicario attach <QUERY> <FILE>
|
||||
```
|
||||
|
||||
`<QUERY>` is the item's ID or a case-insensitive title substring. Example:
|
||||
|
||||
```
|
||||
relicario attach "GitHub" recovery-codes.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Listing an item's attachments
|
||||
|
||||
```
|
||||
relicario attachments <QUERY>
|
||||
```
|
||||
|
||||
This prints the item's attachments with their **attachment IDs** (AIDs) — you need an AID to extract or remove a specific file.
|
||||
|
||||
Example output (format may vary):
|
||||
|
||||
```
|
||||
attachments on "GitHub login":
|
||||
a3f8c1d2e5b6… recovery-codes.txt (4.2 KB)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting a file back out — `extract`
|
||||
|
||||
```
|
||||
relicario extract <QUERY> <AID> [--out <PATH>]
|
||||
```
|
||||
|
||||
- `<QUERY>` — item ID or title substring
|
||||
- `<AID>` — attachment ID from `relicario attachments`
|
||||
- `--out <PATH>` — where to write the file (defaults to the original filename in the current directory if omitted)
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
relicario attachments "GitHub"
|
||||
# note the AID, e.g. a3f8c1d2e5b6…
|
||||
|
||||
# Extract to a specific path:
|
||||
relicario extract "GitHub" a3f8c1d2e5b6 --out ~/Downloads/recovery-codes.txt
|
||||
|
||||
# Or omit --out to write it under its original name in the current folder:
|
||||
relicario extract "GitHub" a3f8c1d2e5b6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Removing one attachment — `detach`
|
||||
|
||||
`detach` removes a single attachment blob. The item itself stays in the vault.
|
||||
|
||||
```
|
||||
relicario detach <QUERY> <AID>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
relicario detach "GitHub" a3f8c1d2e5b6
|
||||
```
|
||||
|
||||
This is different from `relicario purge <QUERY>`, which permanently deletes the **entire item** and all its attachments at once.
|
||||
|
||||
---
|
||||
|
||||
## Size limits
|
||||
|
||||
Relicario enforces configurable size caps to keep your vault from ballooning. View the current limits:
|
||||
|
||||
```
|
||||
relicario settings show
|
||||
```
|
||||
|
||||
To change them:
|
||||
|
||||
```
|
||||
relicario settings attachment-cap [OPTIONS]
|
||||
```
|
||||
|
||||
Available options:
|
||||
|
||||
| Flag | What it controls |
|
||||
|---|---|
|
||||
| `--per-attachment-max-bytes <N>` | Maximum size of a single attachment |
|
||||
| `--per-item-max-count <N>` | Maximum number of attachments on one item |
|
||||
| `--per-vault-soft-cap-bytes <N>` | Vault-wide soft cap (warning threshold) |
|
||||
| `--per-vault-hard-cap-bytes <N>` | Vault-wide hard cap (blocks new attachments) |
|
||||
|
||||
Pass any combination of flags; omitted flags keep their current values.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Goal | Command |
|
||||
|---|---|
|
||||
| Store a file as its own vault item | `relicario add document --file <FILE> --title …` |
|
||||
| Attach a file to an existing item | `relicario attach <QUERY> <FILE>` |
|
||||
| List an item's attachments (get AIDs) | `relicario attachments <QUERY>` |
|
||||
| Extract an attachment to disk | `relicario extract <QUERY> <AID> [--out <PATH>]` |
|
||||
| Remove one attachment (keep the item) | `relicario detach <QUERY> <AID>` |
|
||||
| Remove the whole item + all attachments | `relicario purge <QUERY>` |
|
||||
| View attachment size limits | `relicario settings show` |
|
||||
| Change attachment size limits | `relicario settings attachment-cap [OPTIONS]` |
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Organizing your vault](organizing.md)
|
||||
73
user_docs/concepts.md
Normal file
73
user_docs/concepts.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# How Relicario works
|
||||
|
||||
This page explains the core ideas behind Relicario — no technical jargon required. Read this before anything else and the rest of the docs will make much more sense.
|
||||
|
||||
---
|
||||
|
||||
## Two factors, one key
|
||||
|
||||
Relicario requires **two things** to unlock your vault:
|
||||
|
||||
1. **Your passphrase** — something you remember and type. Think of it like the combination to a safe.
|
||||
2. **Your reference photo** — a JPEG image you chose when you set up your vault. Relicario hid a random secret inside the photo's pixels at setup time. You keep this file safe; it never gets uploaded to your git repository.
|
||||
|
||||
Neither one alone does anything. They're like two keys that must turn at the same time — if either is missing, the vault stays shut.
|
||||
|
||||
A helpful way to picture it: imagine a safety deposit box that needs both your key *and* the bank's key to open. Your passphrase is your key; your reference photo is the other. Lose either one, and you can't get in.
|
||||
|
||||
> **For the curious:** Relicario derives the vault key using Argon2id (a memory-hard key derivation function) and encrypts everything with XChaCha20-Poly1305. See [../docs/CRYPTO.md](../docs/CRYPTO.md) for the full pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Your data lives on your own git server
|
||||
|
||||
Relicario stores your vault in a **git repository that you control** — hosted on your own Gitea instance or on GitHub. There is no Relicario-run cloud, no Relicario account, and no subscription.
|
||||
|
||||
**The trust model in one sentence:** the server only ever stores ciphertext — your passphrase, your image secret, and your decrypted data never leave your device.
|
||||
|
||||
Even if someone broke into your git server and downloaded every file, all they would have is encrypted noise. The keys to decrypt it never leave your machine.
|
||||
|
||||
---
|
||||
|
||||
## What's stored where
|
||||
|
||||
When you initialize a vault, Relicario creates a few files inside your repository:
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `.relicario/` | Config directory: key-derivation salt and parameters (not secret) |
|
||||
| `manifest.enc` | Encrypted index — lets the app list item titles without decrypting everything |
|
||||
| `settings.enc` | Encrypted vault settings |
|
||||
| `items/<id>.enc` | One encrypted file per item |
|
||||
| `attachments/<item-id>/<att-id>.enc` | Encrypted attachment blobs |
|
||||
| `reference.jpg` | Your reference photo — **gitignored, never committed** |
|
||||
|
||||
The last line is the important one: **your reference photo is deliberately excluded from git.** If you only back up your git remote, you have only backed up half of what you need. See [the golden rule](#the-golden-rule) below.
|
||||
|
||||
Because your vault is a normal git repository, its full history is preserved automatically. Every change you make creates a new commit, giving you an audit trail you can browse with any standard git tool.
|
||||
|
||||
---
|
||||
|
||||
## Personal vault vs. org (team) vault
|
||||
|
||||
Most people use a **personal vault** — one vault, one person, one set of two factors. That's what the rest of these docs cover.
|
||||
|
||||
Relicario also supports a **multi-user org vault** for teams: an owner can add members, create collections (groups of items), and grant or revoke access per collection. Roles (owner, admin, member) control what each person can do, and every action is recorded in a signed audit log. The same trust model applies — the server only ever sees ciphertext, and access is enforced by collection grants, not by server-side permissions.
|
||||
|
||||
An org vault stores the same seven item types as a personal vault — logins, secure notes, identities, cards, keys, documents, and TOTP codes — added with `relicario org add <type> --collection <name> …`. Members only ever see the collections they have been granted.
|
||||
|
||||
For day-to-day use, see [Sync & backup](sync-and-backup.md) for syncing an org vault across devices.
|
||||
|
||||
---
|
||||
|
||||
## The golden rule
|
||||
|
||||
> **Never forget your passphrase, and always keep at least one safe copy of your reference photo.**
|
||||
>
|
||||
> Losing your passphrase means your vault cannot be decrypted — there is no password reset and no backdoor. Losing your reference photo *and* your recovery QR (even if you still know your passphrase) has the same result. Losing both factors means your data is gone for good, by design.
|
||||
>
|
||||
> **What to do:** print or photograph your recovery QR and store it somewhere safe and offline. See [Recovery](recovery.md) for the full instructions.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Items](items.md)
|
||||
130
user_docs/faq.md
Normal file
130
user_docs/faq.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
Answers to common questions about how Relicario works, where your data lives, and what happens when things go wrong.
|
||||
|
||||
---
|
||||
|
||||
## What makes Relicario different? Why does it need two factors just to unlock my vault?
|
||||
|
||||
Most password managers protect your vault with a single master password. Relicario requires **two factors** at the same time: your **passphrase** (something you remember) and a **reference photo** (a JPEG you chose during setup that carries a hidden 256-bit secret). Neither one can decrypt your vault on its own — both must be present together.
|
||||
|
||||
The result: even if someone steals your passphrase, they cannot open your vault without also having your reference photo. Even if someone steals the reference photo file, they cannot open your vault without knowing your passphrase. For the technical details, see [../docs/CRYPTO.md](../docs/CRYPTO.md).
|
||||
|
||||
---
|
||||
|
||||
## Where is my data actually stored?
|
||||
|
||||
Your vault is a **git repository that you control**. Relicario stores everything — encrypted items, an encrypted index, and settings — as files inside that repo, which you push to your own Gitea or GitHub server. Nothing goes to any server Relicario runs; there is no Relicario-operated cloud. The server only ever receives opaque ciphertext.
|
||||
|
||||
---
|
||||
|
||||
## Is my reference photo uploaded to the server?
|
||||
|
||||
No. Your reference photo (the JPEG that carries the hidden image secret) is your second unlock factor. Relicario adds it to `.gitignore` at vault creation, so it is **never committed** and never pushed to your remote. If you only back up your git repository, you have **not** backed up your second factor.
|
||||
|
||||
Keep at least one safe copy of `reference.jpg` somewhere separate — an encrypted external drive, a secure cloud folder of your own choice, or a printed [Recovery QR](recovery.md).
|
||||
|
||||
---
|
||||
|
||||
## What if I lose my passphrase or my reference photo?
|
||||
|
||||
There is no password reset and no backdoor — by design. Here is the hard truth:
|
||||
|
||||
- **Lose your passphrase** → your vault is unrecoverable (the recovery QR is also locked by your passphrase).
|
||||
- **Lose your reference photo AND your recovery QR** (even if you still know your passphrase) → the hidden image secret is gone → unrecoverable.
|
||||
- **Lose both factors** → your data is gone for good.
|
||||
|
||||
See [Recovery](recovery.md) for how to generate a recovery QR while you still have everything, and where to keep it safe.
|
||||
|
||||
---
|
||||
|
||||
## Can I use Relicario on multiple computers?
|
||||
|
||||
Yes. Because your vault is a git repo, you can sync it across machines with:
|
||||
|
||||
```
|
||||
relicario sync
|
||||
```
|
||||
|
||||
This does a `git pull --rebase` then `git push` against your configured remote. You will need to copy your `reference.jpg` to each computer you use (it is never in the repo, so you transfer it manually). You can also register each machine as a named device with `relicario device add`.
|
||||
|
||||
---
|
||||
|
||||
## Does Relicario work in my browser?
|
||||
|
||||
Yes — there is a browser extension. It supports **Chrome/Chromium** (primary) and **Firefox (MV3)**. The extension lets you search your vault, reveal or copy fields, see live 30-second TOTP codes, and autofill login forms. The Chrome-only fullscreen vault tab supports all item types, add/edit/delete, settings, trash, devices, history, backup, and LastPass import.
|
||||
|
||||
Install is currently build-from-source (no web-store listing). See [The browser extension](the-browser-extension.md) for step-by-step setup.
|
||||
|
||||
---
|
||||
|
||||
## Can my team share a vault?
|
||||
|
||||
Relicario includes an org vault feature (`relicario org …`) with owner/admin/member roles, collections, per-collection access grants, and a signed audit log. It supports the full set of item types — logins, secure notes, identities, cards, keys, documents, and TOTP — added with `relicario org add <type> --collection <name> …`. The server only sees ciphertext; access is enforced by collection grants.
|
||||
|
||||
**Current limitation:** the browser extension does **not** yet support org vaults — there is no org switch, browse, or write support in the extension. Org vault access is CLI-only for now.
|
||||
|
||||
---
|
||||
|
||||
## How do I generate a strong password?
|
||||
|
||||
Use the built-in generator:
|
||||
|
||||
```
|
||||
relicario generate
|
||||
```
|
||||
|
||||
By default (outside a vault) it produces a 20-character random password with a safe symbol set. Inside an initialized vault it falls back to whatever you have set as your generator defaults. You can also generate a BIP39 word passphrase:
|
||||
|
||||
```
|
||||
relicario generate --bip39
|
||||
```
|
||||
|
||||
See [Passwords and generators](passwords-and-generators.md) for all options, including how to save your preferred defaults to the vault.
|
||||
|
||||
---
|
||||
|
||||
## How do I store 2FA codes (TOTP)?
|
||||
|
||||
You can store TOTP secrets in two ways:
|
||||
|
||||
1. **Standalone TOTP item** — stores just the authenticator code, separate from a login:
|
||||
```
|
||||
relicario add totp --title "GitHub 2FA" --issuer "GitHub" --secret <BASE32>
|
||||
```
|
||||
2. **Attached to a login** — scan the site's QR code image and attach the TOTP secret directly to an existing login:
|
||||
```
|
||||
relicario add login --title "GitHub" --username you@example.com --totp-qr qr.png
|
||||
# or add it later:
|
||||
relicario edit "GitHub" --totp-qr qr.png
|
||||
```
|
||||
|
||||
The browser extension shows live 30-second codes inline for both login and standalone TOTP items. See [TOTP](totp.md) for more detail.
|
||||
|
||||
---
|
||||
|
||||
## How do I move off LastPass?
|
||||
|
||||
Export your data from LastPass as a CSV (LastPass → Account Options → Export), then run:
|
||||
|
||||
```
|
||||
relicario import lastpass /path/to/lastpass-export.csv
|
||||
```
|
||||
|
||||
Each row becomes a new item in your vault. Rows that fail to parse are skipped and reported on stderr. Title collisions are kept as-is (no automatic deduplication).
|
||||
|
||||
---
|
||||
|
||||
## Is there a mobile app?
|
||||
|
||||
Not yet. There are no Relicario mobile apps at this time.
|
||||
|
||||
---
|
||||
|
||||
## Is Relicario free / open source?
|
||||
|
||||
Yes. Relicario is free and open source, released under the **GNU General Public License v3.0 or later** (GPL-3.0-or-later). You can read, build, run, and modify it yourself — which is also what lets you verify that your secrets never leave your device. See the `LICENSE` file in the repository for the full terms.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Back to the guide index](README.md)
|
||||
189
user_docs/getting-started.md
Normal file
189
user_docs/getting-started.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Getting started with Relicario
|
||||
|
||||
This page walks you through building Relicario from source, creating your first vault, and adding your first login — from zero to password manager in about ten minutes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build and install the CLI
|
||||
|
||||
Relicario is currently distributed as source code, so you will need two tools installed first:
|
||||
|
||||
- **git** — used to clone the source now, and to sync your vault later. If you don't have it, see [git-scm.com/downloads](https://git-scm.com/downloads).
|
||||
- **The Rust toolchain** (`cargo`) — if you don't have it yet, visit [rustup.rs](https://rustup.rs) and follow the instructions there.
|
||||
|
||||
Clone the Relicario repository and move into it:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.adlee.work:2222/alee/relicario.git
|
||||
cd relicario
|
||||
```
|
||||
|
||||
> Substitute the URL of whichever Relicario repository you were given access to.
|
||||
|
||||
Then, from the project root, build the CLI:
|
||||
|
||||
```bash
|
||||
# Fast debug build (good for trying things out)
|
||||
cargo build -p relicario-cli
|
||||
|
||||
# Optimized release build (recommended for everyday use)
|
||||
cargo build --release -p relicario-cli
|
||||
```
|
||||
|
||||
The debug binary ends up at `target/debug/relicario`. The release binary ends up at `target/release/relicario`. You can copy either one to somewhere on your `$PATH`, for example:
|
||||
|
||||
```bash
|
||||
cp target/release/relicario ~/.local/bin/relicario
|
||||
```
|
||||
|
||||
Or run it in place using `cargo run`:
|
||||
|
||||
```bash
|
||||
cargo run -p relicario-cli -- --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Create your first vault
|
||||
|
||||
A Relicario vault is a regular git repository, and `relicario init` sets that up for you. Create an empty directory, move into it, and initialize the vault inside.
|
||||
|
||||
```bash
|
||||
mkdir ~/my-vault
|
||||
cd ~/my-vault
|
||||
relicario init --image /path/to/your-photo.jpg
|
||||
```
|
||||
|
||||
Replace `/path/to/your-photo.jpg` with any JPEG you own — a snapshot from your phone, a landscape photo, anything works.
|
||||
|
||||
### What happens during `init`
|
||||
|
||||
1. Relicario asks you to **choose a passphrase**, then to type it again to confirm. Pick something memorable but not trivial — Relicario rejects very weak passphrases. Your typing is not echoed to the terminal.
|
||||
2. It generates a random 256-bit secret and hides it inside the pixels of your photo, writing a new file called `reference.jpg` (in the vault directory by default).
|
||||
3. It sets up the git repository, creates the encrypted vault files, adds a `.gitignore`, and makes the first commit.
|
||||
4. It prints something like:
|
||||
|
||||
```
|
||||
Vault initialized at /home/you/my-vault
|
||||
Reference image: reference.jpg
|
||||
→ back this file up somewhere safe; it is your second factor.
|
||||
```
|
||||
|
||||
### The two factors — and why the reference image matters
|
||||
|
||||
Relicario uses **two factors** to unlock your vault:
|
||||
|
||||
- **Factor 1 — your passphrase:** something you memorize and type at the terminal.
|
||||
- **Factor 2 — your reference image:** the `reference.jpg` file produced during `init`. It carries the hidden secret that, combined with your passphrase, derives the decryption key. Neither factor alone can unlock anything.
|
||||
|
||||
**Critical:** `reference.jpg` is intentionally **not** committed to your git repository (it is listed in `.gitignore`). This means if you push your vault to a git remote, the second factor does NOT go with it — which is exactly the point.
|
||||
|
||||
> **Back this file up separately.** Copy `reference.jpg` to a USB drive, encrypted cloud storage, or a printed recovery QR (see [Recovery](recovery.md)). If you lose your reference image AND your recovery QR, your vault data is gone for good — there is no backdoor.
|
||||
|
||||
For the technically curious, the cryptographic details are in [../docs/CRYPTO.md](../docs/CRYPTO.md) and the threat model is in [../docs/SECURITY.md](../docs/SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Your first unlock
|
||||
|
||||
Every Relicario command that reads or writes vault data unlocks the vault first, then drops the key when it exits. There is no persistent "unlocked session" in the CLI.
|
||||
|
||||
Run commands from **inside your vault directory** (the one containing `.relicario/`) or any subdirectory of it — Relicario walks up to find the vault, just like git does.
|
||||
|
||||
When you run a vault command, Relicario:
|
||||
|
||||
1. Looks for your reference image via the `RELICARIO_IMAGE` environment variable; if not set, checks for `reference.jpg` in the vault directory; if neither is found, prompts `Reference image path:`.
|
||||
2. Prompts `Passphrase:` (input is hidden).
|
||||
|
||||
If both are correct, the vault opens. If either is wrong or the image has been modified, the command fails.
|
||||
|
||||
### Set `RELICARIO_IMAGE` to avoid the prompt
|
||||
|
||||
If `reference.jpg` is not in the vault directory (for example, you keep it on a separate USB drive), set the environment variable so you are not prompted every time:
|
||||
|
||||
```bash
|
||||
export RELICARIO_IMAGE=/media/usb/my-vault-reference.jpg
|
||||
```
|
||||
|
||||
Add that line to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to make it permanent. If `reference.jpg` lives right in the vault directory, Relicario finds it automatically and you do not need to set this variable.
|
||||
|
||||
---
|
||||
|
||||
## 4. Add your first login
|
||||
|
||||
```bash
|
||||
relicario add login \
|
||||
--title "GitHub" \
|
||||
--username "yourname@example.com" \
|
||||
--url "https://github.com" \
|
||||
--password-prompt
|
||||
```
|
||||
|
||||
- `--title` is the name you will use to find the item later.
|
||||
- `--username` and `--url` are stored in plaintext (visible in listings).
|
||||
- `--password-prompt` asks for your password at a hidden prompt. Alternatively, pass `--password <PASSWORD>` inline or `--password-stdin` to pipe it in from a script.
|
||||
|
||||
Any flag you leave out is asked interactively, so you can also just run:
|
||||
|
||||
```bash
|
||||
relicario add login
|
||||
```
|
||||
|
||||
...and answer the prompts one at a time.
|
||||
|
||||
After entering your vault passphrase, the item is encrypted and committed to git.
|
||||
|
||||
---
|
||||
|
||||
## 5. View and list your items
|
||||
|
||||
### View a specific item
|
||||
|
||||
```bash
|
||||
relicario get GitHub
|
||||
```
|
||||
|
||||
Pass an item's title (or any case-insensitive substring of it) as the query. Secrets like the password are **masked** by default — they show as `********`.
|
||||
|
||||
To reveal the actual values:
|
||||
|
||||
```bash
|
||||
relicario get GitHub --show
|
||||
```
|
||||
|
||||
To copy the primary secret (the password for a login) to your clipboard:
|
||||
|
||||
```bash
|
||||
relicario get GitHub --copy
|
||||
```
|
||||
|
||||
The clipboard is automatically cleared after **30 seconds**.
|
||||
|
||||
### List all items
|
||||
|
||||
```bash
|
||||
relicario list
|
||||
```
|
||||
|
||||
Filter by type, group, or tag:
|
||||
|
||||
```bash
|
||||
relicario list --type login
|
||||
relicario list --group Work
|
||||
relicario list --tag important
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. What to do next
|
||||
|
||||
You now have a working vault with your first login. A few things worth doing right away:
|
||||
|
||||
- **Back up your reference image.** Copy `reference.jpg` somewhere safe before you add more data to the vault. See [Recovery](recovery.md) for the recovery QR option.
|
||||
- **Push your vault to a remote.** Add a Gitea or GitHub remote and run `relicario sync` to push. Your ciphertext is safe to store on a server — the server never sees your passphrase or your reference image.
|
||||
- **Learn about the item types.** Relicario can store logins, secure notes, identities, payment cards, API/SSH keys, documents, and TOTP codes. See [Items](items.md).
|
||||
- **Try the browser extension.** Relicario has a Chrome/Chromium extension with autofill, live TOTP codes, and a full-featured vault tab. See [The browser extension](the-browser-extension.md).
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Concepts](concepts.md)
|
||||
317
user_docs/items.md
Normal file
317
user_docs/items.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Items: the 7 types, and how to add, view, edit, and delete them
|
||||
|
||||
This page covers every item type Relicario supports, the exact flags for creating each one, and all the commands for viewing, updating, and removing items.
|
||||
|
||||
---
|
||||
|
||||
## How items work
|
||||
|
||||
Relicario stores secrets as *typed items* — each type has a fixed set of fields that match what you'd expect for that kind of secret (a login has a username and password; a card has a number and CVV; and so on).
|
||||
|
||||
Secret fields — passwords, card numbers, TOTP secrets, key material — are entered at a **hidden prompt** by default so they never appear on screen. If you're scripting or piping input, use the corresponding `--*-stdin` flag to read the value from standard input instead.
|
||||
|
||||
Every item type shares three optional organizational flags:
|
||||
|
||||
| Flag | What it does |
|
||||
|------|--------------|
|
||||
| `--title <TITLE>` | Human-readable name shown in lists |
|
||||
| `--group <GROUP>` | A single folder-like label |
|
||||
| `--tags <TAGS>` | Comma-separated labels, e.g. `--tags work,banking` |
|
||||
|
||||
Any field you don't pass on the command line is prompted for interactively — so you can run `relicario add login` with no flags at all and fill everything in at the prompts.
|
||||
|
||||
---
|
||||
|
||||
## The 7 item types
|
||||
|
||||
### Login
|
||||
|
||||
A username/password pair, optionally with a URL and a TOTP secret baked in.
|
||||
|
||||
```
|
||||
relicario add login [OPTIONS]
|
||||
|
||||
--title <TITLE>
|
||||
--username <USERNAME>
|
||||
--url <URL>
|
||||
--password <PASSWORD> set password from the command line (visible in shell history)
|
||||
--password-prompt prompt for password (hidden input)
|
||||
--password-stdin read password from stdin (one line)
|
||||
--favorite mark as a favorite
|
||||
--totp-qr <PATH> decode an otpauth:// QR image to fill the TOTP secret
|
||||
--group <GROUP>
|
||||
--tags <TAGS>
|
||||
```
|
||||
|
||||
**Example — create a login, prompting for the password:**
|
||||
|
||||
```bash
|
||||
relicario add login --title "GitHub" --username octocat --url https://github.com --password-prompt
|
||||
```
|
||||
|
||||
**Example — scripted, password from stdin:**
|
||||
|
||||
```bash
|
||||
echo "hunter2" | relicario add login --title "GitHub" --username octocat --password-stdin
|
||||
```
|
||||
|
||||
If you have a TOTP QR code saved as an image, `--totp-qr` decodes the `otpauth://` URI and links the TOTP secret to this login automatically. See [TOTP codes](totp.md) for more.
|
||||
|
||||
---
|
||||
|
||||
### Secure note
|
||||
|
||||
A free-form text note. The body is entered interactively (type your note and press Ctrl-D to finish), or piped in via `--body-stdin`.
|
||||
|
||||
```
|
||||
relicario add secure-note [OPTIONS]
|
||||
|
||||
--title <TITLE>
|
||||
--body-stdin read the note body from stdin (to EOF)
|
||||
--group <GROUP>
|
||||
--tags <TAGS>
|
||||
```
|
||||
|
||||
**Example — pipe a note body from a file:**
|
||||
|
||||
```bash
|
||||
relicario add secure-note --title "Wi-Fi passwords" --body-stdin < notes.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Identity
|
||||
|
||||
Personal details: full name, email, phone, and date of birth. None of these are secret fields — they're all prompted as plain text.
|
||||
|
||||
```
|
||||
relicario add identity [OPTIONS]
|
||||
|
||||
--title <TITLE>
|
||||
--full-name <FULL_NAME>
|
||||
--email <EMAIL>
|
||||
--phone <PHONE>
|
||||
--date-of-birth <DATE_OF_BIRTH>
|
||||
--group <GROUP>
|
||||
--tags <TAGS>
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
relicario add identity --title "Personal" --full-name "Alex Rivera" --email alex@example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Card
|
||||
|
||||
A payment card. The card number, CVV, and PIN are secrets — they're prompted (hidden input) unless you use the `--*-stdin` flags. The holder name and expiry are plain text.
|
||||
|
||||
```
|
||||
relicario add card [OPTIONS]
|
||||
|
||||
--title <TITLE>
|
||||
--holder <HOLDER> cardholder name (plain text)
|
||||
--expiry <EXPIRY> expiry date (plain text)
|
||||
--kind <KIND> card type [default: credit]
|
||||
--number-stdin read card number from stdin (one line)
|
||||
--cvv-stdin read CVV from stdin (one line)
|
||||
--pin-stdin read PIN from stdin (one line)
|
||||
--group <GROUP>
|
||||
--tags <TAGS>
|
||||
```
|
||||
|
||||
**Example — create a credit card, entering number/CVV/PIN at hidden prompts:**
|
||||
|
||||
```bash
|
||||
relicario add card --title "Chase Sapphire" --holder "Alex Rivera" --expiry "12/28"
|
||||
```
|
||||
|
||||
Relicario will prompt you for the card number, CVV, and PIN in turn, with each hidden.
|
||||
|
||||
---
|
||||
|
||||
### Key
|
||||
|
||||
A cryptographic key or any block of key material (SSH private key, API key, certificate, etc.). The material itself is a multiline secret entered interactively (Ctrl-D to finish) or via `--material-stdin`.
|
||||
|
||||
```
|
||||
relicario add key [OPTIONS]
|
||||
|
||||
--title <TITLE>
|
||||
--label <LABEL>
|
||||
--algorithm <ALGORITHM>
|
||||
--material-stdin read key material from stdin (to EOF)
|
||||
--group <GROUP>
|
||||
--tags <TAGS>
|
||||
```
|
||||
|
||||
**Example — store an SSH private key:**
|
||||
|
||||
```bash
|
||||
relicario add key --title "Server deploy key" --label "id_ed25519" --algorithm ed25519 \
|
||||
--material-stdin < ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Document
|
||||
|
||||
A file stored encrypted inside your vault. The `--file` flag is required; the file's bytes are encrypted and kept as an attachment on the item.
|
||||
|
||||
```
|
||||
relicario add document --file <FILE> [OPTIONS]
|
||||
|
||||
--title <TITLE>
|
||||
--file <FILE> path to the file to encrypt and store (required)
|
||||
--group <GROUP>
|
||||
--tags <TAGS>
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
relicario add document --title "Passport scan" --file ~/Documents/passport.pdf
|
||||
```
|
||||
|
||||
To get the file back out later: `relicario attachments "Passport scan"` to find the attachment ID, then `relicario extract "Passport scan" <AID> --out passport.pdf`. See [Attachments & Documents](attachments-and-documents.md) for more.
|
||||
|
||||
---
|
||||
|
||||
### TOTP
|
||||
|
||||
A standalone TOTP authenticator entry (think: an item that generates the rotating 6-digit codes for two-factor login). If you want to attach a TOTP secret to an existing Login instead, use `--totp-qr` on `add login` or `edit`.
|
||||
|
||||
```
|
||||
relicario add totp [OPTIONS]
|
||||
|
||||
--title <TITLE>
|
||||
--issuer <ISSUER>
|
||||
--label <LABEL>
|
||||
--secret <SECRET> base32-encoded TOTP secret (visible in shell history)
|
||||
--secret-stdin read TOTP secret from stdin (one line)
|
||||
--period <PERIOD> [default: 30]
|
||||
--digits <DIGITS> [default: 6]
|
||||
--algorithm <ALGORITHM> [default: sha1]
|
||||
--group <GROUP>
|
||||
--tags <TAGS>
|
||||
```
|
||||
|
||||
**Example — add a TOTP entry, secret from stdin:**
|
||||
|
||||
```bash
|
||||
echo "JBSWY3DPEHPK3PXP" | relicario add totp --title "GitHub 2FA" \
|
||||
--issuer GitHub --label octocat --secret-stdin
|
||||
```
|
||||
|
||||
Most services use the defaults (30-second period, 6 digits, SHA-1), so you usually only need `--issuer`, `--label`, and `--secret`/`--secret-stdin`. See [TOTP codes](totp.md) for how to view live codes.
|
||||
|
||||
---
|
||||
|
||||
## Viewing items
|
||||
|
||||
### Get a single item
|
||||
|
||||
```bash
|
||||
relicario get <QUERY>
|
||||
```
|
||||
|
||||
`<QUERY>` is either an item ID or a **case-insensitive title substring** — so `relicario get github` finds any item with "github" in the title.
|
||||
|
||||
By default, secret fields are **masked** (shown as `********`). Two flags change that:
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--show` | Print all secret values in plaintext |
|
||||
| `--copy` | Copy the primary secret (Login password, Card number, etc.) to the clipboard — auto-clears after 30 seconds |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
relicario get "Chase Sapphire" # shows masked fields
|
||||
relicario get "Chase Sapphire" --show # reveals card number and CVV
|
||||
relicario get "GitHub" --copy # copies password; clears in 30s
|
||||
```
|
||||
|
||||
### List all items
|
||||
|
||||
```bash
|
||||
relicario list [OPTIONS]
|
||||
|
||||
--type <TYPE> filter by item type (login, card, totp, …)
|
||||
--group <GROUP> filter by group label
|
||||
--tag <TAG> filter by tag
|
||||
--trashed show trashed items instead of live ones
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
relicario list # all live items
|
||||
relicario list --type login # logins only
|
||||
relicario list --group banking # everything in the "banking" group
|
||||
relicario list --tag work # everything tagged "work"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Editing items
|
||||
|
||||
```bash
|
||||
relicario edit <QUERY>
|
||||
```
|
||||
|
||||
Editing is interactive: Relicario shows each field's current value and lets you type a new one. **Leave a prompt blank and press Enter to keep the existing value.**
|
||||
|
||||
For login items, you can update the linked TOTP secret from a QR code image:
|
||||
|
||||
```bash
|
||||
relicario edit "GitHub" --totp-qr /path/to/qr.png
|
||||
```
|
||||
|
||||
### Field history
|
||||
|
||||
Relicario captures the previous value of secret fields whenever you change them (e.g., after a password rotation). To see that history:
|
||||
|
||||
```bash
|
||||
relicario history <QUERY> # all captured changes, masked
|
||||
relicario history <QUERY> --show # reveal old values
|
||||
relicario history <QUERY> --field login_password # one field only
|
||||
```
|
||||
|
||||
Field key examples: `login_password`, `card_number`, `totp_secret`.
|
||||
|
||||
---
|
||||
|
||||
## Deleting items
|
||||
|
||||
Relicario has two deletion modes: **soft delete** (reversible, moves to trash) and **permanent purge**.
|
||||
|
||||
### Soft delete and restore
|
||||
|
||||
```bash
|
||||
relicario rm <QUERY> # move to trash (reversible)
|
||||
relicario restore <QUERY> # pull it back out of the trash
|
||||
```
|
||||
|
||||
### Permanent purge
|
||||
|
||||
```bash
|
||||
relicario purge <QUERY> # delete the item AND all its attachments, forever
|
||||
```
|
||||
|
||||
There is no undo for `purge`.
|
||||
|
||||
### Trash management
|
||||
|
||||
```bash
|
||||
relicario trash list # see what's in the trash
|
||||
relicario trash empty # permanently purge all items past the trash retention window
|
||||
```
|
||||
|
||||
The retention window is configurable via `relicario settings trash-retention`.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Passwords & generators](passwords-and-generators.md)
|
||||
162
user_docs/organizing.md
Normal file
162
user_docs/organizing.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Organizing your vault: groups, tags, favorites, and search
|
||||
|
||||
This page covers how to keep your vault tidy as it grows — sorting items into groups,
|
||||
tagging them for cross-cutting labels, marking favorites, and finding things quickly.
|
||||
|
||||
---
|
||||
|
||||
## Groups — one folder-like label per item
|
||||
|
||||
A **group** is a single label that acts like a folder. Every item can belong to at most
|
||||
one group. Use groups for broad categories (a room in the house, a project, a person in
|
||||
the family).
|
||||
|
||||
**Set a group when adding an item:**
|
||||
|
||||
```
|
||||
relicario add login --title "Home router" --username admin --group Home
|
||||
relicario add card --title "Visa credit" --group Finance
|
||||
```
|
||||
|
||||
**Change a group on an existing item:**
|
||||
|
||||
Run `relicario edit <title-or-id>` and, when prompted for the group field, type the new
|
||||
value (or press Enter to keep the current one).
|
||||
|
||||
**List items in a group:**
|
||||
|
||||
```
|
||||
relicario list --group Home
|
||||
relicario list --group Finance
|
||||
```
|
||||
|
||||
Group names are case-sensitive, so be consistent — `Home` and `home` are different groups.
|
||||
|
||||
---
|
||||
|
||||
## Tags — multiple labels per item
|
||||
|
||||
**Tags** let you attach several labels to one item — useful when a single item fits more
|
||||
than one category. A login for your bank's app might belong to the `Finance` group and
|
||||
also carry tags `mobile` and `2fa-enabled`.
|
||||
|
||||
**Set tags when adding an item** (comma-separated, no spaces around commas):
|
||||
|
||||
```
|
||||
relicario add login --title "Bank mobile app" \
|
||||
--username me@example.com \
|
||||
--group Finance \
|
||||
--tags mobile,2fa-enabled
|
||||
```
|
||||
|
||||
**Change tags on an existing item:**
|
||||
|
||||
Run `relicario edit <title-or-id>` and update the tags field when prompted.
|
||||
|
||||
**Filter by tag:**
|
||||
|
||||
```
|
||||
relicario list --tag mobile
|
||||
relicario list --tag 2fa-enabled
|
||||
```
|
||||
|
||||
Only one tag at a time can be passed to `--tag`, but you can combine tag filtering with
|
||||
group and type filters (see [Combining filters](#combining-filters) below).
|
||||
|
||||
---
|
||||
|
||||
## Favorites — quick-access marker for login items
|
||||
|
||||
Login items support a `--favorite` flag. Mark an item as a favorite when you add it:
|
||||
|
||||
```
|
||||
relicario add login --title "Gmail" --username me@example.com --favorite
|
||||
```
|
||||
|
||||
This is a quick-access marker — it shows up in the browser extension's favorites view so
|
||||
your most-used logins are always one click away.
|
||||
|
||||
> To toggle a favorite on an existing login, run `relicario edit <title-or-id>` and
|
||||
> update the favorite field when prompted.
|
||||
|
||||
---
|
||||
|
||||
## Finding things
|
||||
|
||||
### Find by title substring — `relicario get`
|
||||
|
||||
`relicario get` accepts a title substring (case-insensitive) or an exact item id:
|
||||
|
||||
```
|
||||
relicario get bank # finds any item whose title contains "bank"
|
||||
relicario get a3f2c1 # finds the item with that id
|
||||
```
|
||||
|
||||
Secrets are masked by default. Pass `--show` to reveal them, or `--copy` to copy the
|
||||
primary secret (Login password, Card number, etc.) to the clipboard — it clears
|
||||
automatically after 30 seconds.
|
||||
|
||||
### Filter the full list — `relicario list`
|
||||
|
||||
`relicario list` prints every item. Narrow it down with flags:
|
||||
|
||||
| Flag | What it filters |
|
||||
|---|---|
|
||||
| `--type <TYPE>` | Item type: `login`, `secure-note`, `identity`, `card`, `key`, `document`, `totp` |
|
||||
| `--group <GROUP>` | Items in that group |
|
||||
| `--tag <TAG>` | Items carrying that tag |
|
||||
| `--trashed` | Trashed (soft-deleted) items only |
|
||||
|
||||
### Combining filters
|
||||
|
||||
All filters can be combined in one command:
|
||||
|
||||
```
|
||||
# All login items in the Finance group tagged "2fa-enabled"
|
||||
relicario list --type login --group Finance --tag 2fa-enabled
|
||||
|
||||
# Every trashed item in the Work group
|
||||
relicario list --group Work --trashed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A suggested scheme (non-prescriptive)
|
||||
|
||||
There is no one right answer, but here is a pattern that works well for a household or
|
||||
small team:
|
||||
|
||||
**Groups → broad areas of life.** Think of them like drawers:
|
||||
- `Home` — router, smart devices, alarm panel
|
||||
- `Finance` — bank accounts, investment logins, card numbers
|
||||
- `Work` — corporate VPN, project tools, SSH keys
|
||||
- `Personal` — social accounts, streaming services, email
|
||||
|
||||
**Tags → cross-cutting traits** that apply across groups:
|
||||
- `2fa-enabled` — logins that have TOTP set up
|
||||
- `shared` — items you share with a family member
|
||||
- `critical` — accounts you would be in serious trouble losing
|
||||
|
||||
**Favorites → your daily drivers.** Mark the five or ten logins you open every day.
|
||||
They surface at the top in the extension so you do not have to search.
|
||||
|
||||
This keeps groups stable (you rarely add a new drawer) while tags stay flexible (add new
|
||||
ones whenever a new trait comes up).
|
||||
|
||||
---
|
||||
|
||||
## Shell-completion tip
|
||||
|
||||
If you build the optional shell completions, `--group` autocompletes from a local cache
|
||||
that Relicario maintains. Run:
|
||||
|
||||
```
|
||||
relicario completions bash > /etc/bash_completion.d/relicario # bash
|
||||
relicario completions zsh > ~/.zfunc/_relicario # zsh
|
||||
```
|
||||
|
||||
After that, pressing Tab after `--group` suggests your existing group names.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Sync & backup](sync-and-backup.md)
|
||||
152
user_docs/passwords-and-generators.md
Normal file
152
user_docs/passwords-and-generators.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Generating strong passwords & passphrases
|
||||
|
||||
This page covers how to generate passwords and word passphrases with Relicario, how to check the strength of a candidate passphrase, and how to save your preferred generator settings so you never have to repeat yourself.
|
||||
|
||||
---
|
||||
|
||||
## Two modes: random characters vs. word passphrases
|
||||
|
||||
`relicario generate` has two modes:
|
||||
|
||||
| Mode | What you get | When to use it |
|
||||
|---|---|---|
|
||||
| Random characters (default) | `x7#Kp!mQr9Ew2@LnVdZ` | Site passwords, API keys, anything with a character limit |
|
||||
| BIP39 word passphrase (`--bip39`) | `olive bridge furnace tangle whisper` | Your vault passphrase, disk encryption, anywhere you have to type it by hand |
|
||||
|
||||
---
|
||||
|
||||
## Quick examples
|
||||
|
||||
Generate a random 20-character password (default settings):
|
||||
|
||||
```
|
||||
relicario generate
|
||||
```
|
||||
|
||||
Generate a longer, 32-character password:
|
||||
|
||||
```
|
||||
relicario generate -l 32
|
||||
```
|
||||
|
||||
Generate a 6-word BIP39 passphrase:
|
||||
|
||||
```
|
||||
relicario generate --bip39 -w 6
|
||||
```
|
||||
|
||||
Generate a BIP39 passphrase with dashes between words instead of spaces:
|
||||
|
||||
```
|
||||
relicario generate --bip39 --separator -
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## All flags
|
||||
|
||||
```
|
||||
relicario generate [-l/--length N] [--bip39] [-w/--words N] [--symbols <SET>] [--separator <S>]
|
||||
```
|
||||
|
||||
| Flag | What it does |
|
||||
|---|---|
|
||||
| `-l`, `--length N` | Total length of the random-character password |
|
||||
| `--bip39` | Switch to BIP39 word-passphrase mode |
|
||||
| `-w`, `--words N` | Number of BIP39 words to include |
|
||||
| `--symbols <SET>` | Symbol set: `safe`, `extended`, or a custom literal (e.g. `!@#`) |
|
||||
| `--separator <S>` | Word separator for BIP39 mode (e.g. a dash or an underscore) |
|
||||
|
||||
### About `--symbols`
|
||||
|
||||
- **`safe`** — a conservative set that works on most sites without paste-rejection headaches.
|
||||
- **`extended`** — a broader set of symbols for maximum entropy when sites permit it.
|
||||
- **custom literal** — pass exactly the characters you want, e.g. `--symbols '!@#'`.
|
||||
|
||||
---
|
||||
|
||||
## Defaults
|
||||
|
||||
Outside a vault, the built-in defaults are:
|
||||
|
||||
- Random mode: length **20**, symbol set **safe**
|
||||
- BIP39 mode: **5** words, separator **space**
|
||||
|
||||
Inside an initialized vault, any flag you leave off falls back to your **saved generator defaults** (see below). This means `relicario generate` inside your vault already "knows" your preferences — you only have to set them once.
|
||||
|
||||
---
|
||||
|
||||
## Saving your generator defaults
|
||||
|
||||
Run this once inside your vault directory. Any flag you pass gets saved; flags you omit stay at their current value.
|
||||
|
||||
Switch to BIP39 mode and use 6 words by default:
|
||||
|
||||
```
|
||||
relicario settings generator-defaults --bip39 --words 6
|
||||
```
|
||||
|
||||
Switch to random-character mode with length 24 and extended symbols:
|
||||
|
||||
```
|
||||
relicario settings generator-defaults --random --length 24 --symbols extended
|
||||
```
|
||||
|
||||
Set a custom word separator for BIP39 mode:
|
||||
|
||||
```
|
||||
relicario settings generator-defaults --bip39 --separator -
|
||||
```
|
||||
|
||||
Full flag reference for `settings generator-defaults`:
|
||||
|
||||
| Flag | What it does |
|
||||
|---|---|
|
||||
| `--random` | Switch the saved default mode to random-character |
|
||||
| `--bip39` | Switch the saved default mode to BIP39 passphrase |
|
||||
| `--length N` | Random mode: default password length |
|
||||
| `--words N` | BIP39 mode: default number of words |
|
||||
| `--symbols <SET>` | Random mode: default symbol charset (`safe`, `extended`, or a custom literal) |
|
||||
| `--separator <S>` | BIP39 mode: default word separator |
|
||||
|
||||
To see all current settings (including your saved generator defaults), run:
|
||||
|
||||
```
|
||||
relicario settings show
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking a passphrase's strength
|
||||
|
||||
Before committing to a passphrase, you can score it:
|
||||
|
||||
```
|
||||
relicario rate "olive bridge furnace tangle whisper sunset"
|
||||
```
|
||||
|
||||
Or pipe it in from stdin (keeps it out of your shell history — recommended):
|
||||
|
||||
```
|
||||
relicario rate -
|
||||
```
|
||||
|
||||
Relicario prints a **score from 0 to 4** and an estimated number of guesses. Higher is better; aim for a 4 for anything important.
|
||||
|
||||
> **Informational only.** The `rate` command does not block you from using a weak passphrase. It is purely a tool to help you make an informed choice.
|
||||
|
||||
---
|
||||
|
||||
## A note on your vault passphrase
|
||||
|
||||
Your **vault passphrase** is Factor 1 of your two-factor encryption — the thing you type to unlock everything. A few things worth knowing:
|
||||
|
||||
- Make it **strong and memorable**. You cannot reset it if you forget it (there is no recovery path for a lost passphrase — see [Recovery](recovery.md) for the full picture).
|
||||
- A BIP39 word passphrase is a great choice: easy to write down and type, hard to guess. Generate a few candidates with `relicario generate --bip39` and use `relicario rate` to compare them.
|
||||
- Your passphrase is always entered locally, at a hidden prompt. It never leaves your device.
|
||||
|
||||
For a plain-language explanation of why both your passphrase AND your reference image are required to unlock your vault, see [Concepts](concepts.md).
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Two-factor codes (TOTP)](totp.md)
|
||||
81
user_docs/recovery.md
Normal file
81
user_docs/recovery.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Recovery — if something goes wrong
|
||||
|
||||
This page covers what you can do when you lose access to your vault, what Relicario can recover, and — just as importantly — what it cannot.
|
||||
|
||||
---
|
||||
|
||||
## The two factors, plainly stated
|
||||
|
||||
Unlocking your Relicario vault always requires two things working together:
|
||||
|
||||
- **Factor 1 — your passphrase.** The phrase you type every time you unlock. Only you know it; it is never stored anywhere.
|
||||
- **Factor 2 — your reference image.** The JPEG photo you chose at setup. Relicario hid a random secret inside its pixels. You keep this file yourself; it is intentionally excluded from your git repository.
|
||||
|
||||
Relicario mixes these two factors together to derive the key that decrypts your data. Neither factor alone is enough. If either one is missing or wrong, the vault cannot open.
|
||||
|
||||
---
|
||||
|
||||
## The Recovery QR — your offline backup of Factor 2
|
||||
|
||||
The Recovery QR is a safety net for your reference image secret. Think of it as a printable backup of Factor 2, sealed shut by Factor 1.
|
||||
|
||||
Here is what that means in practice:
|
||||
|
||||
- **It is encrypted.** The QR encodes your image secret locked behind your passphrase. Someone who finds your printed QR still needs your passphrase to use it.
|
||||
- **It is not saved to disk.** Relicario prints it to your terminal as text art and then forgets it. You must print or photograph it immediately.
|
||||
- **Storing it offline (paper, safe) is the point.** A digital copy on the same device as your reference image does not add much protection.
|
||||
|
||||
### Making a Recovery QR
|
||||
|
||||
Run this inside your vault directory:
|
||||
|
||||
```
|
||||
relicario recovery-qr generate
|
||||
```
|
||||
|
||||
Relicario will read your reference image, ask for your vault passphrase, and print a QR code as ASCII art in the terminal. The code is **not written to any file**. Print it, photograph it, or copy the text — then store it somewhere safe and offline (a fireproof safe, a safety deposit box, a trusted family member's home).
|
||||
|
||||
### Using a Recovery QR (advanced / last resort)
|
||||
|
||||
If you have lost your reference image but still have the printed QR and your passphrase:
|
||||
|
||||
```
|
||||
relicario recovery-qr unwrap
|
||||
```
|
||||
|
||||
Paste the base64 payload you scanned from the QR, then enter your passphrase. Relicario prints the recovered image secret as a hex string.
|
||||
|
||||
Think of this as a safe for the secret itself, not a one-click restore: it proves your Factor 2 can be recovered as long as you keep the QR **and** remember your passphrase. Turning that raw secret back into a ready-to-use reference image is an advanced step — so for everyday peace of mind, the simplest safety net is to keep a spare copy of your reference image (or an [`--include-image` backup archive](sync-and-backup.md)). Then "recovery" is just putting the file back and unlocking as usual.
|
||||
|
||||
---
|
||||
|
||||
## The hard truth
|
||||
|
||||
There is no password reset. There is no backdoor. Not even the project authors can recover your data. This is intentional — it is what ensures the server hosting your git repository can never read your vault, even under legal compulsion.
|
||||
|
||||
Here is what happens in each loss scenario:
|
||||
|
||||
| What you lose | Result |
|
||||
|---|---|
|
||||
| Your **passphrase** (even with both the reference image and the recovery QR) | **Unrecoverable.** The recovery QR is itself locked by your passphrase. No passphrase means nothing can be decrypted. |
|
||||
| Your **reference image AND your recovery QR** (even if you remember the passphrase) | **Unrecoverable.** The image secret is gone. Without it, the two-factor key derivation cannot complete. |
|
||||
| Only your **reference image** (but you have a printed recovery QR) | Recoverable — use `relicario recovery-qr unwrap` as described above. |
|
||||
| Only your **recovery QR** (but you still have the reference image file) | No problem. Your reference image is your active Factor 2. Make a fresh recovery QR when you can. |
|
||||
|
||||
The underlying principle: losing both factors — your passphrase and your image secret — means the data is gone for good. This is not a limitation to be patched; it is the security guarantee that protects your data from everyone, including the people who wrote Relicario.
|
||||
|
||||
---
|
||||
|
||||
## Do this now — a short checklist
|
||||
|
||||
Before you need any of this:
|
||||
|
||||
- [ ] **Back up your reference image.** Copy `reference.jpg` (or whatever you named it) to at least one location that is separate from your computer — an encrypted external drive, a USB stick stored safely offline, or a secure cloud backup you control. Your git remote does **not** contain it; the file is gitignored by design.
|
||||
- [ ] **Print a Recovery QR.** Run `relicario recovery-qr generate`, print the output, and store the paper copy somewhere you would still find it if your devices were all lost or destroyed.
|
||||
- [ ] **Confirm you can recall your passphrase.** If you are not confident, consider whether your memorization strategy is reliable. There is no recovery path for a forgotten passphrase.
|
||||
|
||||
For guidance on encrypted `.relbak` backup archives (which can optionally bundle the reference image), see [Sync & backup](sync-and-backup.md).
|
||||
|
||||
---
|
||||
|
||||
**Next:** [FAQ](faq.md)
|
||||
110
user_docs/sync-and-backup.md
Normal file
110
user_docs/sync-and-backup.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Syncing across devices & backing up
|
||||
|
||||
This page covers how to keep your vault in sync across machines, how to create a portable encrypted backup, and why you need more than just your git remote to have a full backup.
|
||||
|
||||
---
|
||||
|
||||
## Syncing your vault between devices
|
||||
|
||||
Relicario stores your vault in a git repo. Every item you add, edit, or delete is committed there. To push your latest changes to another machine (or pull a teammate's changes), run:
|
||||
|
||||
```
|
||||
relicario sync
|
||||
```
|
||||
|
||||
Under the hood, this does a `git pull --rebase` followed by a push to your configured git remote. Your vault is a normal git repository, so you add its remote yourself the usual way — for example, from inside the vault directory, `git remote add origin <your-server-url>` (a self-hosted Gitea/GitHub repo, or any git host you control). Once a remote is configured, any machine that has the vault cloned and a copy of your reference image can `relicario sync` and stay up to date.
|
||||
|
||||
**Check what's in your vault at a glance:**
|
||||
|
||||
```
|
||||
relicario status
|
||||
```
|
||||
|
||||
This prints a summary: how many items and attachments you have, and the timestamp of the last commit.
|
||||
|
||||
---
|
||||
|
||||
## Why your git remote alone is NOT a full backup
|
||||
|
||||
Here is the catch: your reference image — the photo that carries your second factor — is **gitignored**. It never gets committed to your git repo, by design. If someone stole your git remote, they still could not decrypt anything without that image.
|
||||
|
||||
But that also means: **if you back up only your git remote and lose your reference image, you cannot unlock your vault.** The two factors travel separately by design.
|
||||
|
||||
A complete backup has two parts:
|
||||
1. Your git remote (all the encrypted vault data).
|
||||
2. A safe copy of your reference image (the second factor).
|
||||
|
||||
---
|
||||
|
||||
## Creating a portable encrypted backup
|
||||
|
||||
For the safest kind of backup — one you can carry on a USB drive or store in a separate location — use `relicario backup export`. This packs your entire vault into a single encrypted `.relbak` file. You set a **separate backup passphrase** that is independent of your vault passphrase, so the `.relbak` file can be kept and shared separately without revealing your vault credentials.
|
||||
|
||||
**Export a backup:**
|
||||
|
||||
```
|
||||
relicario backup export vault-backup.relbak
|
||||
```
|
||||
|
||||
You will be prompted to set a backup passphrase. This is not the same as your vault passphrase — choose something strong and store it safely.
|
||||
|
||||
**Include your reference image in the backup** (making it fully self-contained):
|
||||
|
||||
```
|
||||
relicario backup export vault-backup.relbak --include-image
|
||||
```
|
||||
|
||||
With `--include-image`, the `.relbak` file contains everything needed to restore and unlock the vault. If your reference image lives somewhere other than the default `reference.jpg` in the vault directory, point to it explicitly:
|
||||
|
||||
```
|
||||
relicario backup export vault-backup.relbak --include-image --image /path/to/your/reference.jpg
|
||||
```
|
||||
|
||||
**Skip the git history** (smaller file, useful for quick snapshot backups):
|
||||
|
||||
```
|
||||
relicario backup export vault-backup.relbak --no-history
|
||||
```
|
||||
|
||||
**Restore from a backup:**
|
||||
|
||||
```
|
||||
relicario backup restore vault-backup.relbak
|
||||
```
|
||||
|
||||
Or restore into a specific directory:
|
||||
|
||||
```
|
||||
relicario backup restore vault-backup.relbak /path/to/new-vault/
|
||||
```
|
||||
|
||||
The target directory must not already contain a `.relicario/` folder — Relicario will stop and tell you if it does.
|
||||
|
||||
---
|
||||
|
||||
## Importing from LastPass
|
||||
|
||||
If you are migrating from LastPass, Relicario can import your data directly from a LastPass CSV export. Unlock your vault first, then run:
|
||||
|
||||
```
|
||||
relicario import lastpass lastpass-export.csv
|
||||
```
|
||||
|
||||
Each row in the CSV becomes a new item. If a row cannot be parsed, it is skipped and the reason is printed to the terminal — the rest of the import continues. Title collisions are kept as separate items (no automatic deduplication).
|
||||
|
||||
---
|
||||
|
||||
## Backup checklist
|
||||
|
||||
> **Before you feel safe, make sure you have all four of these:**
|
||||
|
||||
- [ ] **Your git remote** is reachable and has your latest sync (`relicario sync` before stepping away from a machine).
|
||||
- [ ] **A safe copy of your reference image** stored somewhere independent of the vault — a USB drive, an encrypted cloud folder, or a printed recovery QR (see [Recovery](recovery.md)).
|
||||
- [ ] **Optionally, a `.relbak` file with `--include-image`** stored somewhere offsite. This is the most convenient full restore path — one file, one backup passphrase.
|
||||
- [ ] **Your vault passphrase** memorized. There is no reset and no backdoor.
|
||||
|
||||
For the full picture on what happens if you lose a factor, and how to generate a recovery QR for your reference image, see [Recovery](recovery.md).
|
||||
|
||||
---
|
||||
|
||||
**Next:** [The browser extension](the-browser-extension.md)
|
||||
163
user_docs/the-browser-extension.md
Normal file
163
user_docs/the-browser-extension.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# The Browser Extension
|
||||
|
||||
This page covers how to build and install the Relicario browser extension, set it up for the first time, and use it for everyday password unlocking, autofill, and vault management.
|
||||
|
||||
---
|
||||
|
||||
## What it is
|
||||
|
||||
The Relicario browser extension brings your vault into your browser. It works in **Chrome and Chromium** (the primary target) and **Firefox** (Manifest V3). It gives you a popup for quick unlock, search, and field copying; in-page autofill on login forms; live TOTP codes; and, on Chrome, a fullscreen vault tab where you can add, edit, and manage everything.
|
||||
|
||||
There is no listing in the Chrome Web Store or Firefox Add-ons yet — you build and load it from source.
|
||||
|
||||
---
|
||||
|
||||
## Build it from source
|
||||
|
||||
You need the Relicario source code and [Node.js](https://nodejs.org) available in your terminal. Open a terminal in the root of the Relicario source tree and run these commands in order:
|
||||
|
||||
```bash
|
||||
# Step 1 — build the WebAssembly crypto module
|
||||
npm run build:wasm
|
||||
|
||||
# Step 2 — build the Chrome extension (output goes to extension/dist/)
|
||||
npm run build
|
||||
```
|
||||
|
||||
If you also want a Firefox build:
|
||||
|
||||
```bash
|
||||
npm run build:firefox
|
||||
# Output goes to extension/dist-firefox/
|
||||
```
|
||||
|
||||
To build everything in one shot:
|
||||
|
||||
```bash
|
||||
npm run build:all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Install it in your browser
|
||||
|
||||
### Chrome / Chromium
|
||||
|
||||
1. Go to `chrome://extensions` in the address bar.
|
||||
2. Turn on **Developer mode** (toggle in the top-right corner).
|
||||
3. Click **Load unpacked**.
|
||||
4. Select the `extension/dist/` folder inside the Relicario source tree.
|
||||
|
||||
The Relicario icon should appear in your browser toolbar.
|
||||
|
||||
### Firefox
|
||||
|
||||
1. Go to `about:debugging#/runtime/this-firefox` in the address bar.
|
||||
2. Click **Load Temporary Add-on**.
|
||||
3. Navigate to `extension/dist-firefox/` and select the `manifest.json` file inside it.
|
||||
|
||||
> **Note:** Firefox loads extensions temporarily — they are removed when you close and reopen the browser. Rebuild and reload after each browser restart.
|
||||
|
||||
---
|
||||
|
||||
## First-run setup wizard
|
||||
|
||||
The first time you click the Relicario toolbar icon, a short setup wizard walks you through connecting the extension to a vault.
|
||||
|
||||
### Option A — Create a new vault
|
||||
|
||||
1. Choose **Create a new vault**.
|
||||
2. Select your git host: **Gitea** or **GitHub**. Enter the host URL (for Gitea), your repository, and an access token.
|
||||
3. Upload a carrier photo — any JPEG you own. Relicario will embed a hidden 256-bit secret inside it and produce a **reference image**. Set a strong passphrase (a strength meter guides you; you need a high score to proceed).
|
||||
4. Relicario creates the vault in your remote repository and stores the reference image inside the browser. The reference image is your second factor — it never leaves the browser or goes to your git remote.
|
||||
5. Give this device a name (e.g. "Laptop") and click **Done**.
|
||||
|
||||
### Option B — Attach this device to an existing vault
|
||||
|
||||
1. Choose **Attach to existing vault**.
|
||||
2. Enter your git host details (host URL, repository, access token).
|
||||
3. Upload your existing reference JPEG — the one produced when the vault was first created.
|
||||
4. Enter your passphrase.
|
||||
5. Give this device a name and click **Done**.
|
||||
|
||||
---
|
||||
|
||||
## Everyday use
|
||||
|
||||
Click the Relicario toolbar icon. Type your passphrase and click **Unlock**.
|
||||
|
||||
You only type your passphrase — the reference image is stored once in the browser and re-read automatically on every unlock. All cryptography runs on-device inside WebAssembly; nothing secret is sent over the network.
|
||||
|
||||
Once unlocked, the popup shows your item list. You can:
|
||||
|
||||
- **Search** by typing any part of an item's title.
|
||||
- **Reveal or copy** fields (passwords, card numbers, notes, etc.).
|
||||
- **View live TOTP codes** — they count down and refresh every 30 seconds.
|
||||
|
||||
When you're done, close the popup. The vault locks automatically based on your configured idle timeout (see [Auto-lock](#auto-lock) below).
|
||||
|
||||
---
|
||||
|
||||
## Autofill
|
||||
|
||||
When you land on a login page, Relicario shows a small icon inside the username or password field. Click it to fill the matching login.
|
||||
|
||||
Matching is done by **hostname** — Relicario looks for a login whose URL matches the current page's hostname.
|
||||
|
||||
**The first time you use autofill on a site**, Relicario shows a Trust-On-First-Use prompt asking you to confirm. Once you confirm, it remembers.
|
||||
|
||||
**On form submit**, Relicario can offer to **save a new login** or **update an existing one** if it detects that the credentials differ from what it has stored.
|
||||
|
||||
> **Heads-up on hostname matching:** matching is coarse. `github.com` and `www.github.com` are treated as separate hosts, so a login saved for one will not autofill on the other. You can add both URLs to a login item to cover both.
|
||||
|
||||
**Syncing** is always manual — click **Sync now** in the popup. There is no background polling.
|
||||
|
||||
---
|
||||
|
||||
## The fullscreen vault tab (Chrome only)
|
||||
|
||||
On Chrome, you can open a **fullscreen vault tab** for complete vault management. This is not available in Firefox.
|
||||
|
||||
From the fullscreen tab you can:
|
||||
|
||||
- **Add, edit, and delete** all 7 item types: Login, Secure Note, Identity, Card, SSH/API Key, Document, and TOTP.
|
||||
- Manage **settings**, **devices**, **trash**, and **field history**.
|
||||
- Run a **backup export or restore** (`.relbak` files).
|
||||
- **Import from LastPass** (CSV export).
|
||||
|
||||
For day-to-day password filling the popup is enough; the fullscreen tab is for vault housekeeping.
|
||||
|
||||
---
|
||||
|
||||
## Permissions the extension requests
|
||||
|
||||
| Permission | Why |
|
||||
|---|---|
|
||||
| `storage` | Saves your configuration and the reference image inside the browser. |
|
||||
| `activeTab` | Reads the current tab's URL to match logins for autofill. |
|
||||
| `clipboardWrite` | Copies secrets to the clipboard when you click Copy. |
|
||||
| Host access | Injects the autofill icon into login pages; communicates with your Gitea/GitHub API for sync. |
|
||||
|
||||
---
|
||||
|
||||
## Auto-lock
|
||||
|
||||
You can configure an idle timeout in the extension settings. Options include locking after N minutes of inactivity, or locking immediately when the popup closes. To change it, open the popup while unlocked and look for **Settings → Auto-lock**.
|
||||
|
||||
---
|
||||
|
||||
## Current limits
|
||||
|
||||
A few things to be aware of before you rely on the extension:
|
||||
|
||||
- **No org/enterprise vault support.** If you use Relicario's multi-user org vault, the extension cannot browse, read, or write to it yet. Org support is planned but has no release date.
|
||||
- **Fullscreen vault tab is Chrome-only.** Firefox users get the popup and autofill, but not the full management UI.
|
||||
- **Hostname matching is coarse.** `github.com` and `www.github.com` are separate entries. Save both URLs on a login item if you need both covered.
|
||||
- **One vault per browser install.** You cannot switch between personal vaults in the same browser profile.
|
||||
- **Sync is manual.** There is no push/pull happening in the background — you initiate sync by clicking **Sync now**.
|
||||
- **No mobile apps.** The extension is desktop-browser only.
|
||||
- **No Relicario-run server.** You need your own Gitea instance or a GitHub account. Relicario does not provide hosted storage.
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Recovery](recovery.md)
|
||||
124
user_docs/totp.md
Normal file
124
user_docs/totp.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Two-factor (TOTP) codes
|
||||
|
||||
This page covers how to store TOTP authenticator secrets in Relicario and view live codes in the browser extension.
|
||||
|
||||
---
|
||||
|
||||
## What is a TOTP code?
|
||||
|
||||
When a website offers "two-factor authentication" or "authenticator app" support, it gives you a short secret — usually as a QR code — that your authenticator app uses to generate a fresh 6-digit code every 30 seconds. Those rolling codes are called TOTP codes (Time-based One-Time Passwords).
|
||||
|
||||
Relicario can store that secret alongside your login, so you never have to hunt for a separate app. The browser extension then shows live codes that tick down in real time, right next to your password.
|
||||
|
||||
---
|
||||
|
||||
## Where to find the TOTP secret on a website
|
||||
|
||||
Look for any of these on the site's security or two-factor settings page:
|
||||
|
||||
- A QR code labeled something like "Scan with your authenticator app"
|
||||
- A text link or button that says "Can't scan the QR code?", "Enter key manually", or "Show secret key"
|
||||
|
||||
The manual key is a string of letters and numbers (Base32 encoded — it looks like `JBSWY3DPEHPK3PXP`). You can use either the QR image file or that manual key with Relicario.
|
||||
|
||||
Keep this secret safe. Anyone who has it can generate codes for your account.
|
||||
|
||||
---
|
||||
|
||||
## Two ways to add a TOTP secret
|
||||
|
||||
### Option 1 — Standalone TOTP item
|
||||
|
||||
Use this when the account is purely authenticator-based and you don't have a separate login item for it, or when you just want to keep the TOTP separate.
|
||||
|
||||
```
|
||||
relicario add totp --title "GitHub 2FA" --issuer "GitHub" --label "you@example.com"
|
||||
```
|
||||
|
||||
Relicario will prompt for the Base32 secret at a hidden prompt. Or pass it via flag or stdin:
|
||||
|
||||
```
|
||||
# Pass the secret as a flag (shows in your shell history — prefer the prompt or --secret-stdin)
|
||||
relicario add totp --title "GitHub 2FA" --issuer "GitHub" --label "you@example.com" \
|
||||
--secret JBSWY3DPEHPK3PXP
|
||||
|
||||
# Read it from stdin (keeps it out of shell history)
|
||||
echo "JBSWY3DPEHPK3PXP" | relicario add totp --title "GitHub 2FA" --issuer "GitHub" \
|
||||
--label "you@example.com" --secret-stdin
|
||||
```
|
||||
|
||||
Available flags for `relicario add totp`:
|
||||
|
||||
| Flag | Default | What it sets |
|
||||
|---|---|---|
|
||||
| `--title <TITLE>` | prompted | Item name shown in the vault |
|
||||
| `--issuer <ISSUER>` | prompted | Service name (e.g. `GitHub`) |
|
||||
| `--label <LABEL>` | prompted | Account identifier (e.g. your email) |
|
||||
| `--secret <SECRET>` | prompted | Base32-encoded TOTP secret |
|
||||
| `--secret-stdin` | — | Read secret from stdin instead of prompting |
|
||||
| `--period <PERIOD>` | `30` | Code rotation interval in seconds |
|
||||
| `--digits <DIGITS>` | `6` | Code length |
|
||||
| `--algorithm <ALGORITHM>` | `sha1` | Hash algorithm |
|
||||
| `--group <GROUP>` | — | Folder-like label |
|
||||
| `--tags <TAGS>` | — | Comma-separated tags |
|
||||
|
||||
Most sites use the defaults (30 seconds, 6 digits, SHA-1). Only change these if the site's setup instructions specifically say otherwise.
|
||||
|
||||
### Option 2 — Attach TOTP to an existing Login item
|
||||
|
||||
If you already have (or are creating) a login item for the site, you can attach the TOTP secret directly to it using a QR image file.
|
||||
|
||||
**At creation time:**
|
||||
|
||||
```
|
||||
relicario add login --title "GitHub" --username "you@example.com" \
|
||||
--totp-qr /path/to/github-totp-qr.png
|
||||
```
|
||||
|
||||
**On an existing login:**
|
||||
|
||||
```
|
||||
relicario edit "GitHub" --totp-qr /path/to/github-totp-qr.png
|
||||
```
|
||||
|
||||
The `--totp-qr <PATH>` flag decodes the `otpauth://` QR image and stores the TOTP secret on the login item. No manual typing of the secret required.
|
||||
|
||||
---
|
||||
|
||||
## Viewing live codes
|
||||
|
||||
**In the browser extension:** open the popup and find your TOTP or login item. The extension shows the current 6-digit code with a countdown timer. The code refreshes automatically every 30 seconds. See [The browser extension](the-browser-extension.md) for how to install and use the extension.
|
||||
|
||||
**From the CLI:** the CLI stores the secret but does not display live rotating codes. Run `relicario get "GitHub"` to see the item (the TOTP secret is masked by default; add `--show` to reveal the stored secret).
|
||||
|
||||
---
|
||||
|
||||
## Changing or rotating a TOTP secret
|
||||
|
||||
If a site asks you to reset your authenticator (or you're migrating to a new device), run:
|
||||
|
||||
```
|
||||
relicario edit "GitHub"
|
||||
```
|
||||
|
||||
Relicario prompts you for each field. Press Enter to keep the current value; type (or paste) the new secret when you reach the TOTP secret field. To set it from a new QR image instead:
|
||||
|
||||
```
|
||||
relicario edit "GitHub" --totp-qr /path/to/new-qr.png
|
||||
```
|
||||
|
||||
The old secret is automatically captured in field history before it's overwritten. To review previous secrets:
|
||||
|
||||
```
|
||||
relicario history "GitHub" --field totp_secret
|
||||
```
|
||||
|
||||
Add `--show` to reveal the masked values:
|
||||
|
||||
```
|
||||
relicario history "GitHub" --field totp_secret --show
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Next:** [Attachments & documents](attachments-and-documents.md)
|
||||
Reference in New Issue
Block a user