eleven / messenger Public

web+cli: Vault sharing by membership β€” lock hands out keys, self-enrol, give (drop request/grant) (#1046)

Redesign the locked-Vault access model so sharing follows thread membership
instead of a request/grant/approve/finish dance (Paul: "request access to my
OWN vault?" + 1Password mis-prompts).

- Locking now hands the vault key to each member β€” a `member` handoff sealed to
  their roster key; bots (is_agent) excluded. Each member self-enrols their own
  passkey/Touch ID key, which DROPS their handoff, so a passkey is required
  thereafter. An unlocked insider can give a latecomer access (vault.give
  replaces request+grant). Personal vaults show no access/sharing UI at all.
- New narrow host seam (host.vault.members / openMine) so the Vault app reaches
  roster public keys + the session key without ever holding raw keys.
- CLI: `eleven vault enroll` self-enrols from your handoff (or `-recovery`
  bootstraps on any machine); `give <who>` replaces finish/approve; adds
  client.Roster().
- Go reducer twin + cross-impl vectors + JS/Go tests updated in lockstep;
  recovery wrap (#995) unchanged and still cross-impl-pinned.
- Add form now opts every password manager out, so 1Password et al. stop
  offering to "save" a secret you're storing.
- Docs (locked-vaults, operator-vault-migration) + changelog updated; the
  honest handoff-window caveat is documented.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell eb4110f909f503e969717692a4d4166fafcbe15e parent c248944
14 files changed, +825 −615
  • changelog/2026-07-15-locked-vaults.md +5 −4
  • docs/locked-vaults.md +91 −37
  • docs/operator-vault-migration.md +56 −29
  • eleven/vault.go +131 −95
  • internal/client/client.go +44 −0
  • internal/vault/vault.go +56 −85
  • internal/vault/vault_test.go +2 −9
  • test/apps.test.mjs +38 −23
  • test/event-vectors.test.mjs +2 −7
  • test/event-vectors/vault.json +74 −73
  • test/vaultcrypto.test.mjs +16 −20
  • web/static/apps/vault.js +247 −218
  • web/static/shell.js +37 −0
  • web/static/vaultcrypto.js +26 −15
diff --git a/changelog/2026-07-15-locked-vaults.md b/changelog/2026-07-15-locked-vaults.md
index b53890c..7a6e7fb 100644
--- a/changelog/2026-07-15-locked-vaults.md
+++ b/changelog/2026-07-15-locked-vaults.md
@@ -1,7 +1,8 @@
πŸ€– The Vault can now be **locked with a passkey**. A locked vault's values only
-open after Touch ID / Face ID β€” on any device, in any browser where you've
-enrolled. Anyone in the chat can still *add* a secret without unlocking, and
+open after Touch ID / Face ID β€” on any device, in any browser where you've set up
+your passkey. Anyone in the chat can still *add* a secret without unlocking, and
names and history stay visible; only reading a value asks for the biometric.
-Teammates without access tap **Request access**, and anyone who can unlock the
-vault approves them β€” no secrets change hands outside the encryption. Locking
+Sharing is just membership: add the Vault to a chat and everyone in it is in β€”
+each person sets up their own passkey the first time, and anyone already in can
+give a newcomer access, all without any secret leaving the encryption. Locking
is one-way, and as ever, the server only ever sees ciphertext.
diff --git a/docs/locked-vaults.md b/docs/locked-vaults.md
index 5547ab2..73deba1 100644
--- a/docs/locked-vaults.md
+++ b/docs/locked-vaults.md
@@ -51,14 +51,19 @@ Vault state (already `{ items, log }`) gains:
keyring: {
v: 1,
pub: <b64url raw P-256 public key>,
- wraps: [ { id, kind, label, credId?, wrapped, by, byName, at } ],
- requests: [ { rid, pub, ephWrapped, kind, credId?, label, by, byName, at,
- granted? } ],
+ wraps: [ { id, kind, uid, label, credId?, wrapped, by, byName, at } ],
}
```
-Each **wrap** is the vault private key (pkcs8 bytes) AES-GCM-encrypted under
-one unlocker's KEK, `b64url(iv||ct)`:
+Access follows **thread membership**, not a request queue β€” so the keyring is
+just a flat list of `wraps`, with no pending-request state. Every wrap now
+carries a `uid` naming the member it belongs to: the reducer uses it to drop a
+member's soft handoff the instant they self-enrol a durable wrap, and the UI
+uses it to attribute keys to people.
+
+Each **wrap** is the vault private key AES-GCM-encrypted under one unlocker's
+KEK (`b64url(iv||ct)`) β€” *except* the `member` kind, which is the private key
+ECIES-sealed to a member's identity, not KEK-wrapped:
- `kind: "passkey"` β€” the KEK is HKDF-derived from the passkey's **WebAuthn
PRF output** (`kekFromPRF`, exactly home's `home/web/static/crypto.js`
@@ -78,33 +83,52 @@ one unlocker's KEK, `b64url(iv||ct)`:
check β€” so it raises the bar against a locked/stolen machine, but not against
code already running as the operator. `label` names the operator/machine
("paul @ mac-studio").
-
-Locking, enrolling, and granting are ordinary vault events, folded by the
-same deterministic reducer (malformed β†’ no-op, so hostile events stay safe
+- `kind: "member"` β€” a **soft handoff**, not a biometric wrap at all: the vault
+ private key **ECIES-sealed to a member's roster (identity) public key**
+ (`sealVaultKeyTo`), openable by that member's own signed-in session key with
+ no biometric, because it's sealed to *who they are*, not to a KEK. It exists
+ only to bootstrap a member into a locked vault β€” it's dropped (by `uid`) the
+ moment they self-enrol a durable `passkey`/`keychain` wrap. *Once a passkey is
+ set up, it requires the passkey thereafter.*
+
+Access follows thread membership: **sharing a vault is adding the Vault app to
+a thread** (a DM or group), and everyone in that thread is entitled to the
+vault β€” a personal vault is just your own single-member thread. There is **no
+"request access" anywhere**; a member either already holds a handoff from lock
+time or an insider *gives* them one. (Personal vaults surface none of this: the
+header lock chip β€” lock / unlock / "lock now" β€” is the whole story.)
+
+Locking, enrolling, giving, and revoking are ordinary vault events, folded by
+the same deterministic reducer (malformed β†’ no-op, so hostile events stay safe
inputs, which `groupCapable` requires):
-- `vault.lock { aid, pub, wrap, sealed: { <itemId>: <sealedValue> } }` β€” the
- locker generates the keypair, re-seals every existing value client-side,
- and ships the keyring's first wrap. Reducer: install keyring, swap each
- item's `value` for `sealed`. A vault with a keyring never folds plaintext
- values back in.
-- `vault.enroll { aid, wrap }` β€” add a wrap. Enrolling a *new device of your
- own* requires being unlocked on an old one (you wrap the private key you
- hold); a member with no access uses request/grant below.
-- `vault.revoke { aid, wrapId }` β€” drop a wrap. Housekeeping, **not** crypto
- revocation (see "What this does not do").
-- `vault.request { aid, rid, pub, ephWrapped, kind, credId?, label }` β€” a
- member wanting access enrols their unlocker *first* (creates their KEK),
- generates an **ephemeral P-256 keypair**, publishes the public half, and
- stores the ephemeral private half in the event itself sealed under their
- own KEK (`ephWrapped`) β€” so the pending request survives reloads and other
- devices, yet only they can complete it.
-- `vault.grant { aid, rid, granted: <ECIES(vaultPriv β†’ requester ephPub)> }`
- β€” any *unlocked* member approves (one biometric): unwrap vault key, wrap to
- the requester's ephemeral pub. The requester's client then (their
- biometric) opens `ephWrapped` β†’ ephemeral priv β†’ `granted` β†’ vault priv,
- and immediately emits its own durable `vault.enroll` wrap. Requests and
- their grants clear from state once enrolment lands.
+- `vault.lock { aid, pub, wrap, handoffs: [ <member wrap>, … ], sealed: { <itemId>: <sealedValue> } }`
+ β€” the locker generates the keypair, re-seals every existing value
+ client-side, ships the keyring's first (durable) wrap **and** a `handoffs`
+ array: one `member`-kind wrap for every *other* current thread member, each
+ the vault private key sealed to that member's roster public key
+ (`sealVaultKeyTo`). **Bots (`is_agent`) are excluded** β€” a locked vault must
+ never hand its key to a machine member, whose private key lives on a sidecar
+ box; keyless pending invitees are skipped too. Reducer: install the keyring
+ (the durable wrap + every handoff), swap each item's `value` for `sealed`. A
+ vault with a keyring never folds plaintext values back in.
+- `vault.enroll { aid, wrap }` β€” a member **self-enrols** a durable wrap. A
+ member holding a `member` handoff opens it with their own session (roster)
+ private key β€” no biometric, it's sealed to their identity β€” verifies it opens
+ the vault, mints their own passkey/keychain KEK, wraps the vault key under it,
+ and emits this event. The reducer **drops that member's `member` handoff**
+ (matched by `uid`) as it adds the durable wrap, so from then on they need
+ their passkey/Touch ID. Enrolling an *additional* device of your own works the
+ same way from a device that already holds the key.
+- `vault.give { aid, wrap: <member handoff> }` β€” an already-unlocked member
+ seals the vault key to a *thread member who has no wrap* (joined after lock
+ time, or lost every device): one biometric to unwrap the vault key, then
+ re-seal it to that member's roster public key as a fresh `member` handoff they
+ self-enrol from. This is an insider **giving** access, never an outsider
+ requesting it β€” it replaces the old request+grant round-trip.
+- `vault.revoke { aid, wrapId }` β€” drop a wrap. Never the last wrap, never a
+ recovery wrap. Housekeeping, **not** crypto revocation (see "What this does
+ not do").
- `vault.add` / `vault.change` carry `sealed` instead of `value` once a
keyring exists. The reducer accepts exactly one of the two; a plaintext
`value` arriving at a locked vault (a stale client) folds but renders
@@ -129,8 +153,8 @@ eleven vault list # names + audit lines, never values
eleven vault get <name> # one Touch ID β†’ prints the value
eleven vault set <name> # hidden prompt (never argv/echo) β†’ seal to pub, no unlock needed
eleven vault set -from-file f <name> # dotenv payloads (ops envs; UTF-8 text)
-eleven vault enroll # mint the keychain KEK + post vault.request
-eleven vault approve / finish # the grant ceremony's two ends
+eleven vault enroll # self-enrol: open your member handoff with your session key, wrap under a Touch ID keychain KEK, post vault.enroll
+eleven vault give <who> # enrolled member hands a thread member their own handoff (vault.give)
eleven vault list/get -db <file> # OFFLINE, against a raw DB replica β€” no server
eleven vault -chat ops … # a group chat's shared vault
```
@@ -142,6 +166,15 @@ all. Secret values never appear in argv, shell history, or a Claude
transcript β€” same contract as the sops vault's `add-secret.sh` (hidden
prompts, operator's own terminal).
+`enroll` is a **self-enrol**, not a request: it finds the `member` handoff your
+own session key can already open (no biometric to read it, since it's sealed to
+your identity), verifies it opens the vault, then mints a Touch ID keychain KEK,
+wraps the vault key under it, and posts `vault.enroll` β€” which drops that
+handoff, so a Touch ID is required from then on. `give <who>` is the insider
+side: an already-enrolled operator seals the vault key to a thread member's
+roster key so a latecomer gets a handoff to self-enrol from. Neither verb ever
+"requests" β€” access is a property of thread membership.
+
Honest scope of the macOS gate: it is LocalAuthentication **in this
process** plus keychain encryption at rest β€” not a Secure-Enclave-bound
item. Enclave binding (`kSecAccessControlBiometryCurrentSet` in the
@@ -188,6 +221,26 @@ the vault of record and this runs in parallel.
- **No hiding of names/metadata from members.** Secret *names* and the audit
trail stay readable by all thread members without biometrics β€” that's the
useful directory. Values are the protected class.
+- **The handoff window trades a biometric for no approval.** Between
+ `vault.lock`/`vault.give` and a member's self-enrol, that member's `member`
+ handoff is openable by their **session key alone** β€” no biometric β€” because
+ it's sealed to their identity, not to a KEK. So in that brief window, anything
+ that can use their signed-in session private key (a dumped `~/.eleven`,
+ malware in their logged-in browser) can open the vault key. The moment they
+ self-enrol, the handoff is dropped and a passkey/Touch ID is required
+ thereafter. This is the deliberate cost of a no-approval, membership-based
+ flow β€” the old request/grant model made you enrol a biometric *first*, this
+ one lets you in with your identity and biometric-gates you on the way out.
+- **Multi-device relies on passkey sync.** A durable wrap is bound to one
+ authenticator, so a member reads values on a second device only if the passkey
+ syncs (iCloud Keychain and the like) or they re-give / "Add a passkey on this
+ device" there β€” the same limit the old model had.
+- **A bogus handoff is griefing, not disclosure.** A member can seal a junk
+ `member` handoff to you (same risk class as the old grant); self-enrol catches
+ it, because it verifies the handoff actually opens a real sealed value before
+ wrapping it under your KEK β€” a bad handoff fails loudly rather than enrolling a
+ dead key. Confidentiality is never at stake, only your ability to enrol from
+ that one handoff.
- **Rollback resistance is advisory.** A hostile server can drop or replay
ciphertext events; key-transparency/tamper-evidence work (#573) is the
broader answer, not this layer.
@@ -219,8 +272,8 @@ the vault of record and this runs in parallel.
## Native (iOS / Mac)
App threads render through the webview on both native apps, so the locked
-vault's *surface* (masked rows, sealed adds, request/grant) ships to native
-with the web change. The **unlock** path is the scoped exception: WebAuthn
+vault's *surface* (masked rows, sealed adds, self-enrol from a handoff, give)
+ships to native with the web change. The **unlock** path is the scoped exception: WebAuthn
PRF inside WKWebView isn't dependable across OS versions, so native unlock
ships as a follow-up β€” a `hostBridge` biometric provider backed by a
Secure-Enclave-resident P-256 key (the vault keypair's curve, so an enclave
@@ -232,11 +285,12 @@ deliberate and temporary, not an omission.
## Phasing
1. **Web lock layer** (#946) β€” `vaultcrypto.js` (pure, Node-tested like
- home's crypto) + `vault.js` events/UI: lock, passkey enroll/unlock, sealed
- add/read, request/grant. Changelog entry (user-visible).
+ home's crypto) + `vault.js` events/UI: lock (with per-member handoffs),
+ passkey enroll/unlock, sealed add/read, self-enrol + give. Changelog entry
+ (user-visible).
2. **CLI verbs** (#947) β€” `eleven vault list/get/set` incl. group-vault
discovery.
-3. **CLI biometrics** (#948) β€” keychain KEK + `enroll`/`approve` ceremony.
+3. **CLI biometrics** (#948) β€” keychain KEK + self-enrol (`enroll`) / `give`.
4. **Offline restore** (#949) β€” `eleven vault restore --db` + drill runbook.
5. **Native unlock** (#950) β€” hostBridge + Secure Enclave (follow-up).
6. **Ops migration** (#951) β€” stand up the space, seed, drill, flip.
diff --git a/docs/operator-vault-migration.md b/docs/operator-vault-migration.md
index 431c63c..d7c906d 100644
--- a/docs/operator-vault-migration.md
+++ b/docs/operator-vault-migration.md
@@ -17,6 +17,16 @@ built. So the bar is explicit: no real secret moves until the prerequisites
ship AND the restore drill passes on a cold machine.** sops stays the vault of
record, in parallel, for a full cycle after that.
+The access flow was subsequently simplified from the reviewed request/grant
+ceremony to the **membership/handoff** model of `docs/locked-vaults.md`: sharing
+a vault is adding the Vault app to a thread; `vault.lock` hands a sealed
+`member` handoff to every other member; each self-enrols a durable biometric
+wrap; a latecomer gets a handoff via `vault.give`. This does **not** move the
+security posture β€” confidentiality still rests on the same ECIES sealing and
+biometric wraps β€” and dropping the request/grant round-trip actually *removes*
+the grant-approval forge surface (there is no approval step to MITM). The
+analysis below reflects the membership model.
+
## Why move at all
sops+age works and is secure, but its *sharing* is painful: each operator seals
@@ -44,18 +54,27 @@ Unlock is therefore **per-operator, per-device, and entirely local**: the
in-memory key and the "lock now" / auto-expiry re-mask are properties of *one
device's session*, never shared. Each operator seals new secrets to the public
key with no unlock at all (adding is free); each reads only after their own
-biometric. **Onboarding the second operator** is the request/grant ceremony,
-built and tested end-to-end (vectors + full-ceremony crypto test + live
-round-trip test): Paul locks first (his key is the first wrap); James
-`eleven vault enroll` (mints his own keychain KEK, posts a `vault.request`);
-Paul **approves** with one biometric (out-of-band verified, Β§"Grant-approval");
-James `eleven vault finish` (lands his own durable wrap). The vault private key
-never transits the server in the clear at any step β€” only wraps and
-ephemeral-sealed grants, all opaque blobs. **Verified sound** by the crypto
-review: `ephWrapped` gates completion (only the requester finishes), the grant
-is ECIES-bound to the request's ephemeral public key (a replayed/mismatched
-grant fails to open, never misdirects the key), and a grant can only leak the
-key to a party an already-unlocked granter chose to wrap to.
+biometric. **Onboarding the operators is membership-based** β€” access follows
+thread membership, there is no "request access" step. Paul (or any operator)
+creates the shared ops thread with the other operators and adds the Vault app,
+then **locks** it. `vault.lock` carries, besides Paul's own durable wrap, a
+`member` handoff for every other current member: the vault private key
+ECIES-sealed to that operator's roster public key (`sealVaultKeyTo`; bots are
+excluded, though this space is bot-less anyway). Each operator then
+**self-enrols** β€” opens their handoff with their own session key (no biometric,
+it's sealed to their identity), verifies it opens the vault, and wraps the vault
+key under their own passkey (web) or Touch ID keychain key
+(`eleven vault enroll`), which drops the handoff so a biometric is required
+thereafter. An operator who joins later (or loses every device) gets a fresh
+handoff from any enrolled operator via `vault.give`. The vault private key never
+transits the server in the clear at any step β€” only wraps and identity-sealed
+handoffs, all opaque blobs. **Sound** by the crypto review's verdict on the
+same primitive it examined: a handoff is ECIES-sealed to a member's roster
+public key β€” the identity the roster binds and verifies via the existing
+MAC-bind mechanism, not a self-asserted name β€” so it can only open for that
+member, and only an already-unlocked operator can create one. There is no
+ephemeral-key grant to replay or misdirect, and no approval step to forge,
+because there is no request.
## Security analysis
@@ -190,19 +209,24 @@ stdout), make it the *only* form used in any Claude-adjacent or deploy context,
and keep the bare stdout `get` for interactive human use only. The gate/DR drill
uses `-o`.
-### 8. Grant-approval trust β€” **[verified] out-of-band verify defeats the only forge path**
-
-Approving a `vault.request` hands the vault key to whoever's ephemeral public
-key is in the request. The request rides *inside* the thread-key-encrypted,
-GCM-authenticated blob, and the bot-less server holds no thread key β€” so it can
-neither forge a request nor substitute the ephemeral key. The only forge path is
-a **member / compromised operator session** posting a request with an
-attacker-chosen ephemeral key *and* display name. Mitigation: the approver
-confirms **out-of-band** (over the messenger, a call) that the person is really
-requesting now, before approving β€” the same discipline sops requires before
-adding a `.sops.yaml` recipient. The crypto review confirmed this works
-*precisely because it routes around the forgeable display name* (Β§8-adjacent:
-audit attribution is not authenticated β€” see Β§9).
+### 8. Handoff targeting β€” **[verified] no approval to forge; sealing binds to identity**
+
+The membership model **removes the grant-approval round-trip entirely**, and
+with it the forge surface the old model carried. There is no `vault.request` for
+anyone to approve, so nothing takes an attacker-chosen ephemeral key *and*
+display name and waits for a human to bless it. Instead, `vault.lock` and
+`vault.give` seal the vault key **directly to a member's roster public key**
+(`sealVaultKeyTo`) β€” the identity key the roster already binds and verifies via
+the existing MAC-bind mechanism, not a self-asserted display name. So a handoff
+can only ever open for the member it was sealed to, and only an already-unlocked
+operator can create one. The residual insider risk is unchanged and is
+*availability*, not confidentiality: a compromised session can seal a **bogus**
+handoff to an operator (griefing), caught at self-enrol because it verifies the
+handoff opens a real sealed value before wrapping it (Β§9). The out-of-band
+discipline sops requires before adding a `.sops.yaml` recipient still applies β€”
+but to the human decision of *whom to add to the ops thread*, since there is no
+per-grant approval left to MITM. (Audit attribution is still self-asserted and
+unauthenticated β€” see Β§9.)
### 9. Insider / compromised-session abuse β€” **[gap] worse than "nuisance"; audit is not accountable**
@@ -323,10 +347,13 @@ full cycle afterward.
specifics β†’ RUNBOOK.md.
2. **Streamed replication;** verify the S3 replica appears in the *correct*
bucket (gate step 3).
-3. **Lock + enrol + escrow.** Paul locks first (Β§"shared"); James via
- request β†’ out-of-band-verified approve (Β§8) β†’ finish; both enrol a CLI
- keychain key on each recovery machine; **mint the R1 recovery wrap** and place
- the R3 escrow in 1Password.
+3. **Share + lock + enrol + escrow.** Paul (or any operator) creates the shared
+ ops thread with the other operators and adds the Vault app, then **locks** it
+ β€” `vault.lock` hands a `member` handoff to each other operator automatically
+ (Β§"shared"). Each operator **self-enrols** from their handoff (web passkey, or
+ `eleven vault enroll` β†’ CLI keychain key) on each recovery machine; a latecomer
+ gets a handoff via `vault.give`. **Mint the R1 recovery wrap** and place the R3
+ escrow in 1Password.
4. **Pass the gate** (above) with throwaway secrets, cold-machine recovery
included.
5. **Seed for real.** For each `/data/*.env` / service token in sops **not** on
diff --git a/eleven/vault.go b/eleven/vault.go
index 2a2bfd8..98b4c93 100644
--- a/eleven/vault.go
+++ b/eleven/vault.go
@@ -24,6 +24,7 @@ import (
"golang.org/x/term"
+ "lchat/internal/client"
"lchat/internal/e2ee"
"lchat/internal/vault"
)
@@ -79,14 +80,11 @@ func runVault(spaces []*space, args []string) {
vaultSet(sp, tid, foldVault(sp, tid), rest[0], *fromFile)
case "enroll":
sp, tid, _ := vaultTarget(spaces, *chat)
- vaultEnroll(sp, tid, foldVault(sp, tid), *label)
- case "finish":
- sp, tid, _ := vaultTarget(spaces, *chat)
- vaultFinish(sp, tid, foldVault(sp, tid))
- case "approve":
+ vaultEnroll(sp, tid, foldVault(sp, tid), *label, *recovery)
+ case "give":
who := strings.Join(rest, " ")
sp, tid, _ := vaultTarget(spaces, *chat)
- vaultApprove(sp, tid, foldVault(sp, tid), who)
+ vaultGive(sp, tid, foldVault(sp, tid), who)
case "recovery-wrap":
sp, tid, _ := vaultTarget(spaces, *chat)
vaultRecoveryWrap(sp, tid, foldVault(sp, tid))
@@ -111,10 +109,15 @@ commands:
store/overwrite a value β€” hidden prompt, piped
stdin, or a file; on a locked vault it seals to the
vault key with no unlock needed
- enroll [-label s] ask for access to a locked vault from this machine
- (mints a Touch ID-guarded key, posts the request)
- finish complete an approved enroll (one Touch ID)
- approve [who] approve a pending request (needs your Touch ID key)
+ enroll [-label s] [-recovery]
+ set up THIS machine's key from your handoff β€” the
+ vault key was sealed to you when the vault was locked
+ (or given to you); mints a Touch ID-guarded key. No
+ approval: being in the thread is the entitlement.
+ -recovery bootstraps from the recovery passphrase
+ instead (any machine, when you have no handoff yet).
+ give <who> give a thread member (who has no key yet) access β€”
+ seals the vault key to them (needs your Touch ID key)
recovery-wrap mint the vault's un-revokable recovery key: a
passphrase-wrapped copy of the vault key (escrow the
passphrase in 1Password) that opens on ANY machine
@@ -126,7 +129,8 @@ flags:
/data/<host>.db or an S3 replica restore. No server
anywhere.
-recovery get: open sealed values with the recovery passphrase
- instead of the keychain β€” works on a fresh Mac, a
+ instead of the keychain; enroll: bootstrap this
+ machine's key from it β€” both work on a fresh Mac, a
Linux box, or a CGO_ENABLED=0 build (no keychain).
-o <file> get: write the value to a mode-600 file, never
stdout β€” the deploy/DR-safe form (use this in any
@@ -252,15 +256,25 @@ func vaultOffline(spaces []*space, dbPath, chatNeedle string) (host, threadID, t
func vaultList(title string, s vault.State) {
lock := "not locked β€” values are E2EE to members, with no biometric gate"
if s.Keyring != nil {
+ // Count durable holders separately from members still holding a handoff
+ // (invited, not yet set up) β€” a `member` wrap can't open the vault yet.
+ durable, pending := 0, 0
+ for _, w := range s.Keyring.Wraps {
+ if w.Kind == "member" {
+ pending++
+ } else {
+ durable++
+ }
+ }
keys := "1 key"
- if n := len(s.Keyring.Wraps); n != 1 {
- keys = fmt.Sprintf("%d keys", n)
+ if durable != 1 {
+ keys = fmt.Sprintf("%d keys", durable)
}
lock = "LOCKED Β· " + keys
- if n := len(s.Keyring.Requests); n == 1 {
- lock += " Β· 1 pending request"
- } else if n > 1 {
- lock += fmt.Sprintf(" Β· %d pending requests", n)
+ if pending == 1 {
+ lock += " Β· 1 invited, not yet set up"
+ } else if pending > 1 {
+ lock += fmt.Sprintf(" Β· %d invited, not yet set up", pending)
}
}
fmt.Fprintf(os.Stderr, "%s β€” %s\n", title, lock)
@@ -284,12 +298,10 @@ func vaultList(title string, s vault.State) {
fmt.Println(line)
}
if s.Keyring != nil {
- for _, r := range s.Keyring.Requests {
- state := "wants access β€” `eleven vault approve`, or approve in the app"
- if r.Granted != "" {
- state = "granted β€” waiting for them to finish"
+ for _, w := range s.Keyring.Wraps {
+ if w.Kind == "member" {
+ fmt.Fprintf(os.Stderr, "Β· %s was given access but hasn't set up a key yet\n", orSomeone(w.Label))
}
- fmt.Fprintf(os.Stderr, "Β· request from %s: %s\n", orSomeone(r.ByName), state)
}
}
}
@@ -443,14 +455,64 @@ func vaultSet(sp *space, threadID string, s vault.State, name, fromFile string)
}
}
-// vaultEnroll asks for access from this machine: mint (or reuse) the Touch
-// ID-guarded keychain KEK, publish an ephemeral public key to be granted to,
-// and keep the ephemeral private half in the request itself β€” sealed under the
-// KEK, so only this machine (plus a biometric) can finish.
-func vaultEnroll(sp *space, threadID string, s vault.State, label string) {
+// vaultEnroll sets up THIS machine's key. The normal path: the vault key was
+// sealed to my roster key when the vault was locked (or given to me), so I open
+// that handoff with my session private key β€” no biometric, it's sealed to my
+// identity β€” then re-wrap the vault key under this machine's Touch ID-guarded
+// keychain KEK and enrol it. That drops my `member` handoff (the reducer keys
+// the drop by uid). No approval: being in the thread is the entitlement. The
+// -recovery path instead opens the vault key with the escrowed passphrase β€” the
+// any-machine bootstrap for an operator who has no handoff (e.g. the locker
+// setting up their own CLI, where no one else is enrolled yet to give one).
+func vaultEnroll(sp *space, threadID string, s vault.State, label string, recovery bool) {
if s.Keyring == nil {
die("this vault isn't locked yet β€” lock it in the app first (the first key is a passkey ceremony)")
}
+ me, err := sp.c.Me()
+ if err != nil {
+ die("%v", err)
+ }
+ var handoff *vault.Wrap
+ for i := range s.Keyring.Wraps {
+ w := &s.Keyring.Wraps[i]
+ if w.Kind == "keychain" && w.UID == me {
+ die("this machine's identity already has a key here β€” `eleven vault get <name>` reads values")
+ }
+ if w.Kind == "member" && w.UID == me {
+ handoff = w
+ }
+ }
+ var vaultPriv string
+ if recovery {
+ vaultPriv = recoverVaultPriv(s) // the escrowed passphrase β†’ the vault key, any machine
+ } else {
+ if handoff == nil {
+ die("you don't have a handoff for this vault yet β€” ask someone already in to `eleven vault give %q` (or Give access in the app), or `eleven vault enroll -recovery` to bootstrap from the recovery passphrase", orSomeone(sp.sess.Name))
+ }
+ // Open the handoff with my session (roster) private key β†’ the vault key.
+ var env struct {
+ K string `json:"k"`
+ }
+ if err := e2ee.OpenSealed(handoff.Wrapped, sp.sess.UserPriv, &env); err != nil || env.K == "" {
+ die("couldn't open your vault key β€” ask someone in the vault to `give` you access again")
+ }
+ vaultPriv = env.K
+ }
+ // Integrity: the key must actually open this vault. If any value is sealed, it
+ // has to decrypt β€” a mismatched/hostile handoff fails HERE, before it's
+ // wrapped under the keychain. (The recovery wrap is already authenticated, so
+ // this is belt-and-braces for that path.)
+ for _, it := range s.Items {
+ if it.Sealed != "" {
+ var probe struct {
+ V string `json:"v"`
+ }
+ if err := e2ee.OpenSealed(it.Sealed, vaultPriv, &probe); err != nil {
+ die("that key doesn't match this vault β€” ask for access again")
+ }
+ break
+ }
+ }
if label == "" {
host, _ := os.Hostname()
label = strings.TrimSpace(sp.sess.Name + " β€” CLI @ " + host)
@@ -459,108 +521,82 @@ func vaultEnroll(sp *space, threadID string, s vault.State, label string) {
if err != nil {
die("%v", err)
}
- eph, err := e2ee.GenerateUserKeypair()
- if err != nil {
- die("%v", err)
- }
- ephWrapped, err := wrapUnderKEK(kek, eph.PrivateKey)
+ wrapped, err := wrapUnderKEK(kek, vaultPriv)
if err != nil {
die("%v", err)
}
err = sp.c.SendEvent(threadID, map[string]any{
- "type": "vault.request", "aid": newID(), "rid": newID(),
- "pub": eph.PublicKey, "ephWrapped": ephWrapped, "kind": "keychain", "label": label,
+ "type": "vault.enroll", "aid": newID(),
+ "wrap": map[string]any{"id": newID(), "kind": "keychain", "uid": me, "label": label, "wrapped": wrapped},
})
if err != nil {
die("%v", err)
}
- fmt.Fprintln(os.Stderr, "Access requested. Anyone who can unlock the vault approves it (in the app's Access panel, or `eleven vault approve` on their machine) β€” then run `eleven vault finish` here.")
+ fmt.Fprintln(os.Stderr, "You're in β€” this machine now unlocks the vault with Touch ID (`eleven vault get <name>`).")
}
-// vaultFinish completes an approved request: the keychain KEK (one Touch ID)
-// opens the request's ephemeral key, the ephemeral key opens the grant, and a
-// durable keychain wrap goes into the keyring.
-func vaultFinish(sp *space, threadID string, s vault.State) {
+// vaultGive hands a thread member (who has no key yet) access: unlock the vault
+// key with this machine's keychain (one Touch ID), then seal it to that member's
+// roster key as a `member` handoff β€” which they turn into their own key with
+// `eleven vault enroll` (or Set up your passkey in the app). An insider GIVING,
+// never an outsider asking.
+func vaultGive(sp *space, threadID string, s vault.State, who string) {
if s.Keyring == nil {
die("this vault isn't locked")
}
- me, err := sp.c.Me()
- if err != nil {
- die("%v", err)
- }
- var req *vault.Request
- for i := range s.Keyring.Requests {
- r := &s.Keyring.Requests[i]
- if r.By == me && r.Kind == "keychain" {
- req = r
- }
- }
- if req == nil {
- die("no pending CLI request here β€” `eleven vault enroll` first")
- }
- if req.Granted == "" {
- die("your request hasn't been approved yet β€” someone who can unlock the vault has to approve it")
- }
- kek, err := vaultKEK(kekAccount(spaceHost(sp.sess), threadID), false)
- if err != nil {
- die("%v", err)
- }
- ephPriv, err := openUnderKEK(kek, req.EphWrapped)
- if err != nil {
- die("couldn't open the request's key with this machine's keychain β€” was the request made on another machine?")
+ if strings.TrimSpace(who) == "" {
+ die("who? β€” `eleven vault give <name>` names a thread member to give access to")
}
- priv, err := e2ee.UnwrapThreadKey(req.Granted, ephPriv)
- if err != nil {
- die("the grant doesn't match this request β€” ask for a fresh approval")
- }
- wrapped, err := wrapUnderKEK(kek, priv)
+ me, err := sp.c.Me()
if err != nil {
die("%v", err)
}
- err = sp.c.SendEvent(threadID, map[string]any{
- "type": "vault.enroll", "aid": newID(), "rid": req.ID,
- "wrap": map[string]any{"id": newID(), "kind": "keychain", "label": req.Label, "wrapped": wrapped},
- })
+ members, err := sp.c.Roster()
if err != nil {
die("%v", err)
}
- fmt.Fprintln(os.Stderr, "You're in β€” this machine now unlocks the vault with Touch ID (`eleven vault get <name>`).")
-}
-
-// vaultApprove grants a pending request: this machine's keychain wrap opens
-// the vault key (one Touch ID), which wraps to the requester's ephemeral key.
-func vaultApprove(sp *space, threadID string, s vault.State, who string) {
- if s.Keyring == nil {
- die("this vault isn't locked")
+ held := map[string]bool{}
+ for _, w := range s.Keyring.Wraps {
+ if w.UID != "" {
+ held[w.UID] = true
+ }
}
- var pending []vault.Request
- for _, r := range s.Keyring.Requests {
- if r.Granted == "" && (who == "" || strings.Contains(strings.ToLower(r.ByName+" "+r.Label), strings.ToLower(who))) {
- pending = append(pending, r)
+ needle := strings.ToLower(strings.TrimSpace(who))
+ var match []client.RosterMember
+ for _, m := range members {
+ if m.UserID == me || m.IsAgent || m.PublicKey == "" || held[m.UserID] {
+ continue // me, a bot, keyless, or already holding a key/handoff
+ }
+ if strings.Contains(strings.ToLower(m.Name), needle) {
+ match = append(match, m)
}
}
- switch len(pending) {
+ switch len(match) {
case 0:
- die("no matching pending request β€” `eleven vault list` shows them")
+ die("no thread member without a key matches %q β€” `eleven vault list` shows who's in", who)
case 1:
// fall through
default:
- names := make([]string, len(pending))
- for i, r := range pending {
- names[i] = orSomeone(r.ByName) + " (" + r.Label + ")"
+ names := make([]string, len(match))
+ for i, m := range match {
+ names[i] = orSomeone(m.Name)
}
- die("several pending requests match: %s β€” name one", strings.Join(names, ", "))
+ die("several members match %q: %s β€” be more specific", who, strings.Join(names, ", "))
}
- req := pending[0]
- priv := unlockVaultPriv(spaceHost(sp.sess), threadID, s)
- granted, err := e2ee.WrapThreadKey(priv, req.Pub)
+ target := match[0]
+ priv := unlockVaultPriv(spaceHost(sp.sess), threadID, s) // Touch ID β†’ the vault key
+ wrapped, err := e2ee.SealForPub(target.PublicKey, map[string]string{"k": priv})
if err != nil {
die("%v", err)
}
- if err := sp.c.SendEvent(threadID, map[string]any{"type": "vault.grant", "aid": newID(), "rid": req.ID, "granted": granted}); err != nil {
+ err = sp.c.SendEvent(threadID, map[string]any{
+ "type": "vault.give", "aid": newID(),
+ "wrap": map[string]any{"id": newID(), "kind": "member", "uid": target.UserID, "label": target.Name, "wrapped": wrapped},
+ })
+ if err != nil {
die("%v", err)
}
- fmt.Fprintf(os.Stderr, "Granted β€” %s finishes on their side (`eleven vault finish`, or Finish setup in the app).\n", orSomeone(req.ByName))
+ fmt.Fprintf(os.Stderr, "%s can now set up their key (`eleven vault enroll`, or Set up your passkey in the app).\n", orSomeone(target.Name))
}
// unlockVaultPriv opens the vault private key with this machine's keychain KEK
diff --git a/internal/client/client.go b/internal/client/client.go
index f110f8d..c352ecd 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -447,6 +447,50 @@ func (c *Client) rosterLabels() map[string]string {
return labels
}
+// RosterMember is one member of the space, with the fields the vault's `give`
+// needs: who they are (UserID), their resolved display name, their roster
+// PUBLIC key (to seal a handoff to), and whether they're a bot (IsAgent) β€” a
+// locked vault must never hand its key to a machine member.
+type RosterMember struct {
+ UserID string
+ Name string
+ PublicKey string
+ IsAgent bool
+}
+
+// Roster returns the caller's visible roster with public keys β€” the CLI end of
+// the web's roster read (same /api/roster, same name resolution: a bot's
+// plaintext label, else the instance-key-encrypted label_blob). Used by
+// `eleven vault give` to seal a handoff to a member's roster key.
+func (c *Client) Roster() ([]RosterMember, error) {
+ c.init()
+ var rl struct {
+ Roster []struct {
+ UserID string `json:"user_id"`
+ Label string `json:"label"`
+ LabelBlob string `json:"label_blob"`
+ PublicKey string `json:"public_key"`
+ IsAgent bool `json:"is_agent"`
+ } `json:"roster"`
+ }
+ if err := c.get("/api/roster", &rl); err != nil {
+ return nil, err
+ }
+ instKey, _ := c.InstanceKey()
+ out := make([]RosterMember, 0, len(rl.Roster))
+ for _, e := range rl.Roster {
+ name := e.Label
+ if name == "" && e.LabelBlob != "" && instKey != nil {
+ var n string
+ if e2ee.DecryptJSON(instKey, e.LabelBlob, &n) == nil {
+ name = n
+ }
+ }
+ out = append(out, RosterMember{UserID: e.UserID, Name: name, PublicKey: e.PublicKey, IsAgent: e.IsAgent})
+ }
+ return out, nil
+}
+
// Me returns this member's user id (cached after the first call).
func (c *Client) Me() (string, error) {
if c.uid != "" {
diff --git a/internal/vault/vault.go b/internal/vault/vault.go
index 43ad772..5223c0e 100644
--- a/internal/vault/vault.go
+++ b/internal/vault/vault.go
@@ -39,38 +39,28 @@ type LogEntry struct {
At int64 `json:"at"`
}
-// Wrap is the vault private key sealed under one unlocker's KEK β€” a passkey's
-// PRF-derived key in the browser, or a Touch ID-guarded keychain key here.
+// Wrap is one keyring entry (docs/locked-vaults.md). Kinds:
+// passkey / keychain β€” a member's DURABLE biometric wrap: the vault private
+// key sealed under a PRF-derived (browser) or Touch ID keychain (CLI) KEK.
+// recovery β€” the passphrase-derived, un-revokable break-glass (#995).
+// member β€” a HANDOFF: the vault key sealed to a member's ROSTER
+// public key, opened with their session key alone; dropped once they
+// self-enrol a durable wrap. UID says whose it is.
type Wrap struct {
ID string `json:"id"`
- Kind string `json:"kind"` // "passkey" | "keychain"
+ Kind string `json:"kind"`
Label string `json:"label"`
+ UID string `json:"uid"` // the member this wrap belongs to ("" for a recovery wrap)
CredID string `json:"credId"`
Wrapped string `json:"wrapped"`
}
-// Request is a member asking in: an ephemeral public key to grant to, with the
-// ephemeral private half sealed under the requester's own KEK.
-type Request struct {
- ID string `json:"id"` // the request id (rid)
- Pub string `json:"pub"`
- EphWrapped string `json:"ephWrapped"`
- Kind string `json:"kind"`
- CredID string `json:"credId"`
- Label string `json:"label"`
- Granted string `json:"granted"`
- By string `json:"by"`
- ByName string `json:"byName"`
- At int64 `json:"at"`
-}
-
// Keyring marks a LOCKED vault: the vault public key plus every wrap of its
// private half. Nil keyring = plain vault.
type Keyring struct {
- V int `json:"v"`
- Pub string `json:"pub"`
- Wraps []Wrap `json:"wraps"`
- Requests []Request `json:"requests"`
+ V int `json:"v"`
+ Pub string `json:"pub"`
+ Wraps []Wrap `json:"wraps"`
}
// State is the folded vault, exactly the web reducer's { items, log, keyring }.
@@ -175,22 +165,57 @@ func Fold(s State, e Event) State {
} // uncovered items keep their plaintext; surfaces flag them
items[i] = it
}
+ // Besides the locker's own wrap, fold each `member` handoff β€” the vault
+ // key sealed to another member's roster key, so they can self-enrol. A
+ // non-member entry in handoffs is dropped (a handoff must be a handoff).
+ wraps := []Wrap{*w}
+ if hs, ok := e["handoffs"].([]any); ok {
+ for _, h := range hs {
+ if hw := wrapShape(h); hw != nil && hw.Kind == "member" {
+ wraps = addWrap(wraps, *hw)
+ }
+ }
+ }
return State{
Items: items,
Log: addLog(s.Log, e, "lock", "", ""),
- Keyring: &Keyring{V: 1, Pub: str(e, "pub"), Wraps: []Wrap{*w}, Requests: []Request{}},
+ Keyring: &Keyring{V: 1, Pub: str(e, "pub"), Wraps: wraps},
}
case "vault.enroll":
w := wrapShape(e["wrap"])
- if s.Keyring == nil || str(e, "aid") == "" || w == nil {
+ // An enroll wrap must be DURABLE (never a `member` handoff β€” that would
+ // re-add the soft key the enroll is meant to replace).
+ if s.Keyring == nil || str(e, "aid") == "" || w == nil || w.Kind == "member" {
return s
}
+ uid := w.UID
+ if uid == "" {
+ uid = str(e, "uid")
+ }
kr := *s.Keyring
- kr.Wraps = addWrap(kr.Wraps, *w)
- if rid := str(e, "rid"); rid != "" {
- kr.Requests = removeRequest(kr.Requests, rid)
+ // Drop this member's soft `member` handoff β€” from here on they need their
+ // durable wrap. Then add it.
+ wraps := make([]Wrap, 0, len(kr.Wraps))
+ for _, x := range kr.Wraps {
+ if x.Kind == "member" && uid != "" && x.UID == uid {
+ continue
+ }
+ wraps = append(wraps, x)
}
+ kr.Wraps = addWrap(wraps, *w)
return State{Items: s.Items, Log: addLog(s.Log, e, "enroll", "", w.Label), Keyring: &kr}
+ case "vault.give":
+ // An already-unlocked insider hands a thread member (who has no key yet)
+ // a `member` handoff β€” the vault key sealed to their roster key, which
+ // they self-enrol from. The give replaces request/grant: an insider
+ // GIVING, never an outsider asking. UID (the recipient) is required.
+ w := wrapShape(e["wrap"])
+ if s.Keyring == nil || str(e, "aid") == "" || w == nil || w.Kind != "member" || w.UID == "" {
+ return s
+ }
+ kr := *s.Keyring
+ kr.Wraps = addWrap(kr.Wraps, *w)
+ return State{Items: s.Items, Log: addLog(s.Log, e, "give", "", w.Label), Keyring: &kr}
case "vault.revoke":
wrapID := str(e, "wrapId")
if s.Keyring == nil || str(e, "aid") == "" || wrapID == "" {
@@ -219,33 +244,6 @@ func Fold(s State, e Event) State {
}
kr.Wraps = wraps
return State{Items: s.Items, Log: addLog(s.Log, e, "revoke", "", gone), Keyring: &kr}
- case "vault.request":
- req := requestShape(e)
- if s.Keyring == nil || str(e, "aid") == "" || req == nil {
- return s
- }
- req.By, req.ByName, req.At = str(e, "uid"), str(e, "name"), i64(e, "ts")
- kr := *s.Keyring
- kr.Requests = addRequest(kr.Requests, *req)
- return State{Items: s.Items, Log: addLog(s.Log, e, "request", "", req.Label), Keyring: &kr}
- case "vault.grant":
- rid, granted := str(e, "rid"), str(e, "granted")
- if s.Keyring == nil || str(e, "aid") == "" || rid == "" || granted == "" {
- return s
- }
- idx := -1
- for i, r := range s.Keyring.Requests {
- if r.ID == rid {
- idx = i
- }
- }
- if idx < 0 {
- return s
- }
- kr := *s.Keyring
- kr.Requests = append([]Request(nil), kr.Requests...)
- kr.Requests[idx].Granted = granted
- return State{Items: s.Items, Log: addLog(s.Log, e, "grant", "", kr.Requests[idx].Label), Keyring: &kr}
}
return s
}
@@ -269,24 +267,16 @@ func wrapShape(v any) *Wrap {
if m == nil {
return nil
}
- w := Wrap{ID: str(m, "id"), Kind: str(m, "kind"), Label: str(m, "label"), Wrapped: str(m, "wrapped")}
- // "recovery" (#995) = a passphrase-derived, un-revokable break-glass wrap.
- if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain" && w.Kind != "recovery") {
+ w := Wrap{ID: str(m, "id"), Kind: str(m, "kind"), Label: str(m, "label"), UID: str(m, "uid"), Wrapped: str(m, "wrapped")}
+ // "recovery" (#995) = a passphrase-derived, un-revokable break-glass wrap;
+ // "member" = a soft handoff of the vault key to a member's roster key.
+ if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain" && w.Kind != "recovery" && w.Kind != "member") {
return nil
}
w.CredID = str(m, "credId")
return &w
}
-func requestShape(e Event) *Request {
- r := Request{ID: str(e, "rid"), Pub: str(e, "pub"), EphWrapped: str(e, "ephWrapped"), Kind: str(e, "kind"), Label: str(e, "label")}
- if r.ID == "" || r.Pub == "" || r.EphWrapped == "" || (r.Kind != "passkey" && r.Kind != "keychain") {
- return nil
- }
- r.CredID = str(e, "credId")
- return &r
-}
-
// ---- list helpers (apps.js addItem/updateItem/removeItem twins) --------------
func findItem(items []Item, id string) int {
@@ -314,25 +304,6 @@ func addWrap(wraps []Wrap, w Wrap) []Wrap {
return append(append([]Wrap(nil), wraps...), w)
}
-func addRequest(reqs []Request, r Request) []Request {
- for _, x := range reqs {
- if x.ID == r.ID {
- return reqs
- }
- }
- return append(append([]Request(nil), reqs...), r)
-}
-
-func removeRequest(reqs []Request, id string) []Request {
- out := make([]Request, 0, len(reqs))
- for _, r := range reqs {
- if r.ID != id {
- out = append(out, r)
- }
- }
- return out
-}
-
// addLog appends one audit record, deduped by aid β€” a re-fold never double-logs.
func addLog(log []LogEntry, e Event, action, ref, name string) []LogEntry {
aid := str(e, "aid")
diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go
index ba57f46..365ddc6 100644
--- a/internal/vault/vault_test.go
+++ b/internal/vault/vault_test.go
@@ -66,16 +66,9 @@ func canonical(s State) any {
if s.Keyring != nil {
wraps := make([]any, 0, len(s.Keyring.Wraps))
for _, w := range s.Keyring.Wraps {
- wraps = append(wraps, map[string]any{"id": w.ID, "kind": w.Kind, "label": w.Label, "credId": w.CredID, "wrapped": w.Wrapped})
+ wraps = append(wraps, map[string]any{"id": w.ID, "kind": w.Kind, "label": w.Label, "uid": w.UID, "credId": w.CredID, "wrapped": w.Wrapped})
}
- reqs := make([]any, 0, len(s.Keyring.Requests))
- for _, r := range s.Keyring.Requests {
- reqs = append(reqs, map[string]any{
- "id": r.ID, "pub": r.Pub, "ephWrapped": r.EphWrapped, "kind": r.Kind, "credId": r.CredID,
- "label": r.Label, "granted": r.Granted, "by": r.By, "byName": r.ByName, "at": float64(r.At),
- })
- }
- keyring = map[string]any{"v": float64(s.Keyring.V), "pub": s.Keyring.Pub, "wraps": wraps, "requests": reqs}
+ keyring = map[string]any{"v": float64(s.Keyring.V), "pub": s.Keyring.Pub, "wraps": wraps}
}
return map[string]any{"items": items, "log": log, "keyring": keyring}
}
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 4795054..08ed632 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -1987,10 +1987,12 @@ await test("/roll emits a dice.roll event and replies in place with an Open acti
// ---- πŸ€– the vault lock layer (docs/locked-vaults.md) --------------------------
-// A well-formed keyring wrap/request for reuse below. `wrapped`/`pub`/`granted`
-// are opaque b64url blobs to the reducer β€” no crypto runs in a fold.
+// Well-formed keyring wraps for reuse below. `wrapped` is an opaque b64url blob
+// to the reducer β€” no crypto runs in a fold. W = a durable passkey wrap; M = a
+// `member` handoff (the vault key sealed to member `uid`'s roster key).
const W = (id, extra) => ({ id, kind: "passkey", label: "Paul", credId: "cred-" + id, wrapped: "wrap-" + id, ...extra });
-const LOCK = (over = {}) => ({ type: "vault.lock", aid: "al", pub: "vaultpub", wrap: W("w1"), sealed: {}, ...over });
+const M = (id, uid, label) => ({ id, kind: "member", uid, label: label || "", wrapped: "handoff-" + id });
+const LOCK = (over = {}) => ({ type: "vault.lock", aid: "al", pub: "vaultpub", wrap: W("w1", { uid: "u1" }), sealed: {}, ...over });
const vreduce = vaultApp.reduce;
await test("vault.lock installs the keyring, seals covered items, keeps uncovered plaintext", () => {
@@ -2045,26 +2047,38 @@ await test("vault.change carrying sealed replaces a plaintext value (the Seal pa
assert.equal(s.items[0].value, undefined, "sealing a change drops the plaintext");
});
-await test("vault.enroll adds a wrap, dedupes by id, and rid clears the pending request", () => {
- let s = vreduce(undefined, LOCK());
- s = vreduce(s, { type: "vault.request", aid: "a1", rid: "r1", pub: "ephpub", ephWrapped: "ephw", kind: "passkey", credId: "c2", label: "James", uid: "u2", name: "James" });
- assert.equal(s.keyring.requests.length, 1);
- assert.equal(s.keyring.requests[0].by, "u2");
- s = vreduce(s, { type: "vault.grant", aid: "a2", rid: "r1", granted: "granted-blob" });
- assert.equal(s.keyring.requests[0].granted, "granted-blob");
- s = vreduce(s, { type: "vault.enroll", aid: "a3", rid: "r1", wrap: W("w2", { label: "James" }) });
+await test("vault.lock folds member handoffs; vault.enroll turns one into a durable wrap, dropping it by uid", () => {
+ // Lock hands James (u2) a member handoff alongside the locker's own wrap.
+ let s = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James")] }));
+ assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]);
+ assert.equal(s.keyring.wraps.find((w) => w.id === "h2").kind, "member");
+ // James self-enrols: his member handoff drops, his durable wrap goes in.
+ s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: W("w2", { uid: "u2", label: "James" }), uid: "u2", name: "James" });
assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "w2"]);
- assert.equal(s.keyring.requests.length, 0, "enroll with rid clears the request");
- assert.equal(vreduce(s, { type: "vault.enroll", aid: "a4", wrap: W("w2") }).keyring.wraps.length, 2, "duplicate wrap id dedupes");
+ assert.equal(s.keyring.wraps.some((w) => w.kind === "member"), false, "the handoff is gone once enrolled");
+ assert.equal(s.log.at(-1).action, "enroll");
+ // enrolling a member's wrap NEVER touches another member's handoff (drop is by uid)
+ let two = vreduce(undefined, LOCK({ handoffs: [M("h2", "u2", "James"), M("h3", "u3", "Carol")] }));
+ two = vreduce(two, { type: "vault.enroll", aid: "b1", wrap: W("w2", { uid: "u2" }), uid: "u2", name: "James" });
+ assert.deepEqual(two.keyring.wraps.map((w) => w.id), ["w1", "h3", "w2"], "only the enroller's handoff drops");
+ // duplicate wrap id dedupes; a `member`-kind enroll is rejected (must be durable)
+ assert.equal(vreduce(s, { type: "vault.enroll", aid: "a3", wrap: W("w2", { uid: "u2" }) }).keyring.wraps.length, 2, "duplicate wrap id dedupes");
+ assert.equal(vreduce(s, { type: "vault.enroll", aid: "a4", wrap: M("w9", "u2") }).keyring.wraps.length, 2, "a member wrap can't enroll");
// no keyring β†’ enroll no-ops
assert.equal(vreduce(undefined, { type: "vault.enroll", aid: "a5", wrap: W("w9") }).keyring ?? null, null);
});
-await test("vault.grant needs a matching request; a stranger's junk folds to no-ops", () => {
- const s = vreduce(undefined, LOCK());
- assert.equal(vreduce(s, { type: "vault.grant", aid: "a1", rid: "ghost", granted: "blob" }), s);
- assert.equal(vreduce(s, { type: "vault.request", aid: "a2", rid: "", pub: "p", ephWrapped: "e", kind: "passkey" }), s);
- assert.equal(vreduce(s, { type: "vault.request", aid: "a3", rid: "r", pub: "p", ephWrapped: "e", kind: "usb-fob" }), s, "unknown wrap kind rejected");
+await test("vault.give hands a member a handoff; junk gives fold to no-ops", () => {
+ let s = vreduce(undefined, LOCK());
+ s = vreduce(s, { type: "vault.give", aid: "a1", wrap: M("h2", "u2", "James"), uid: "u1", name: "Paul" });
+ assert.deepEqual(s.keyring.wraps.map((w) => w.id), ["w1", "h2"]);
+ assert.equal(s.log.at(-1).action, "give");
+ assert.equal(s.log.at(-1).name, "James", "the give logs the recipient");
+ // a give without a recipient uid, or of a non-member wrap, is rejected
+ assert.equal(vreduce(s, { type: "vault.give", aid: "a2", wrap: { id: "g1", kind: "member", wrapped: "x" } }).keyring.wraps.length, 2, "give needs a uid");
+ assert.equal(vreduce(s, { type: "vault.give", aid: "a3", wrap: W("g2", { uid: "u3" }) }).keyring.wraps.length, 2, "a give must be a member wrap");
+ // no keyring β†’ give no-ops
+ assert.equal(vreduce(undefined, { type: "vault.give", aid: "a4", wrap: M("g", "u2") }).keyring ?? null, null);
});
await test("vault.revoke drops a wrap but never the last one", () => {
@@ -2096,18 +2110,19 @@ await test("vault: keyring wraps are sanitised β€” a stranger's spare fields nev
const w = s.keyring.wraps[0];
assert.equal("evil" in w, false);
assert.equal("credId" in w, false, "non-string credId dropped");
- assert.deepEqual(Object.keys(w).sort(), ["id", "kind", "label", "wrapped"]);
+ assert.deepEqual(Object.keys(w).sort(), ["id", "kind", "label", "uid", "wrapped"]);
});
-await test("vault: lock/request/grant surface in previews and the group chat projection", () => {
+await test("vault: lock surfaces in previews and the group chat projection; give/enroll stay quiet", () => {
assert.equal(vaultApp.preview({ type: "vault.lock", uid: "u2", name: "Ciara" }), "πŸ”’ Ciara locked the Vault");
- assert.equal(vaultApp.preview({ type: "vault.request", uid: "u1", name: "me" }), "πŸ”’ You requested access to the Vault");
- assert.equal(vaultApp.preview({ type: "vault.grant", uid: "u2", name: "Ciara" }), "πŸ”’ Ciara granted access to the Vault");
+ // give/enroll are routine key housekeeping β€” never previewed, never in the feed.
+ assert.equal(vaultApp.preview({ type: "vault.give", uid: "u1", name: "me" }), null);
+ assert.equal(vaultApp.preview({ type: "vault.enroll", uid: "u2", name: "Ciara" }), null);
const line = vaultApp.chatMessage({ type: "vault.lock" }, { group: true });
assert.equal(line.action, true);
assert.match(line.text, /locked the Vault/);
- // enroll/revoke stay out of the group feed β€” routine key housekeeping.
assert.equal(vaultApp.chatMessage({ type: "vault.enroll" }, { group: true }), null);
+ assert.equal(vaultApp.chatMessage({ type: "vault.give" }, { group: true }), null);
});
await test("appGlyphInto clears a leftover photo background before painting the mark", () => {
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index a966e92..ed3f7c6 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -559,17 +559,12 @@ function canonVault(state) {
updatedBy: i.updatedBy || "", updatedByName: i.updatedByName || "", updatedAt: i.updatedAt || 0,
});
const entry = (l) => ({ id: l.id || "", ref: l.ref || "", action: l.action || "", name: l.name || "", by: l.by || "", byName: l.byName || "", at: l.at || 0 });
- const wrap = (w) => ({ id: w.id || "", kind: w.kind || "", label: w.label || "", credId: w.credId || "", wrapped: w.wrapped || "" });
- const req = (r) => ({
- id: r.id || "", pub: r.pub || "", ephWrapped: r.ephWrapped || "", kind: r.kind || "",
- credId: r.credId || "", label: r.label || "", granted: r.granted || "",
- by: r.by || "", byName: r.byName || "", at: r.at || 0,
- });
+ const wrap = (w) => ({ id: w.id || "", kind: w.kind || "", label: w.label || "", uid: w.uid || "", credId: w.credId || "", wrapped: w.wrapped || "" });
return {
items: (s.items || []).map(item),
log: (s.log || []).map(entry),
keyring: s.keyring
- ? { v: s.keyring.v || 0, pub: s.keyring.pub || "", wraps: (s.keyring.wraps || []).map(wrap), requests: (s.keyring.requests || []).map(req) }
+ ? { v: s.keyring.v || 0, pub: s.keyring.pub || "", wraps: (s.keyring.wraps || []).map(wrap) }
: null,
};
}
diff --git a/test/event-vectors/vault.json b/test/event-vectors/vault.json
index f1b4c3f..fafcc0f 100644
--- a/test/event-vectors/vault.json
+++ b/test/event-vectors/vault.json
@@ -1,11 +1,11 @@
{
- "description": "πŸ€– Vault fold vectors β€” the cross-implementation contract for the Vault app's reducer, INCLUDING the lock layer (docs/locked-vaults.md). The web reducer (web/static/apps/vault.js) and the Go fold (internal/vault) must fold every event_sequence from an empty state to exactly expected_state. States are compared in CANONICAL form: every item/log/keyring field present, \"\" / 0 defaults for absent strings/numbers, keyring null when the vault is unlocked. Vectors are the spec: if one side disagrees, fix the implementation, never the vector.",
+ "description": "πŸ€– Vault fold vectors β€” the cross-implementation contract for the Vault app's reducer, INCLUDING the lock layer (docs/locked-vaults.md). The web reducer (web/static/apps/vault.js) and the Go fold (internal/vault) must fold every event_sequence from an empty state to exactly expected_state. States are compared in CANONICAL form: every item/log/keyring field present, \"\" / 0 defaults for absent strings/numbers, keyring null when the vault is unlocked. Access follows THREAD MEMBERSHIP: a lock hands the vault key to each member (`handoffs`, each a `member` wrap sealed to their roster key); a member self-enrols a durable wrap (`vault.enroll`, which drops their `member` handoff by uid); an insider gives a latecomer a handoff (`vault.give`). There is no request/grant. Vectors are the spec: if one side disagrees, fix the implementation, never the vector.",
"app": "vault",
"source": "web/static/apps/vault.js (events reducer) + internal/vault/vault.go (Fold)",
"state_shape": {
"items": [{ "id": "", "name": "", "value": "", "sealed": "", "kind": "text", "by": "", "byName": "", "at": 0, "updatedBy": "", "updatedByName": "", "updatedAt": 0 }],
- "log": [{ "id": "", "ref": "", "action": "add|change|remove|lock|enroll|revoke|request|grant", "name": "", "by": "", "byName": "", "at": 0 }],
- "keyring": { "v": 1, "pub": "", "wraps": [{ "id": "", "kind": "passkey|keychain", "label": "", "credId": "", "wrapped": "" }], "requests": [{ "id": "", "pub": "", "ephWrapped": "", "kind": "", "credId": "", "label": "", "granted": "", "by": "", "byName": "", "at": 0 }] }
+ "log": [{ "id": "", "ref": "", "action": "add|change|remove|lock|enroll|give|revoke", "name": "", "by": "", "byName": "", "at": 0 }],
+ "keyring": { "v": 1, "pub": "", "wraps": [{ "id": "", "kind": "passkey|keychain|recovery|member", "label": "", "uid": "", "credId": "", "wrapped": "" }] }
},
"vectors": [
{
@@ -68,11 +68,12 @@
}
},
{
- "name": "lock seals covered items, keeps uncovered plaintext, installs the keyring",
+ "name": "lock seals covered items, keeps uncovered plaintext, hands the key to members, installs the keyring",
+ "note": "vault.lock carries the locker's own durable wrap PLUS a `member` handoff per other member (the vault key sealed to their roster key). Both fold into the keyring; the handoff is dropped once that member self-enrols.",
"event_sequence": [
{ "type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000 },
{ "type": "vault.add", "id": "s2", "aid": "a2", "label": "Door", "value": "0000", "uid": "u1", "name": "Paul", "ts": 2000 },
- { "type": "vault.lock", "aid": "a3", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": { "s1": "sealed-s1" }, "uid": "u1", "name": "Paul", "ts": 3000 }
+ { "type": "vault.lock", "aid": "a3", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "handoffs": [{ "id": "h2", "kind": "member", "uid": "u2", "label": "James", "wrapped": "handoff-james" }], "sealed": { "s1": "sealed-s1" }, "uid": "u1", "name": "Paul", "ts": 3000 }
],
"expected_state": {
"items": [
@@ -87,18 +88,21 @@
"keyring": {
"v": 1,
"pub": "vault-pub",
- "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }],
- "requests": []
+ "wraps": [
+ { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" },
+ { "id": "h2", "kind": "member", "uid": "u2", "label": "James", "credId": "", "wrapped": "handoff-james" }
+ ]
}
}
},
{
- "name": "lock is one-way; malformed locks fold to no-ops; junk wrap fields are dropped",
+ "name": "lock is one-way; malformed locks fold to no-ops; junk wrap fields and non-member handoffs are dropped",
+ "note": "a handoff must be a well-formed `member` wrap β€” a passkey-kind entry in `handoffs` is dropped, and spare wrap fields never fold in.",
"event_sequence": [
{ "type": "vault.lock", "aid": "b1", "pub": "", "wrap": { "id": "w0", "kind": "passkey", "wrapped": "x" } },
{ "type": "vault.lock", "aid": "b2", "pub": "p", "wrap": { "id": "w0", "kind": "usb-fob", "wrapped": "x" } },
{ "type": "vault.lock", "aid": "b3", "pub": "p", "wrap": { "id": "w0", "kind": "passkey", "wrapped": "" } },
- { "type": "vault.lock", "aid": "a1", "pub": "first-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1", "evil": "extra" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
+ { "type": "vault.lock", "aid": "a1", "pub": "first-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1", "evil": "extra" }, "handoffs": [{ "id": "hbad", "kind": "passkey", "uid": "u2", "wrapped": "x" }, { "id": "h2", "kind": "member", "uid": "u2", "label": "James", "wrapped": "handoff-james" }], "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
{ "type": "vault.lock", "aid": "a2", "pub": "second-pub", "wrap": { "id": "w2", "kind": "passkey", "label": "Mallory", "credId": "cred-2", "wrapped": "wrap-2" }, "sealed": {}, "uid": "u9", "name": "Mallory", "ts": 2000 }
],
"expected_state": {
@@ -109,45 +113,71 @@
"keyring": {
"v": 1,
"pub": "first-pub",
- "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }],
- "requests": []
+ "wraps": [
+ { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" },
+ { "id": "h2", "kind": "member", "uid": "u2", "label": "James", "credId": "", "wrapped": "handoff-james" }
+ ]
}
}
},
{
- "name": "the grant ceremony: request (keychain kind) β†’ grant β†’ enroll clears the request",
- "note": "kind \"keychain\" is the eleven CLI's wrap kind β€” pinned here so the CLI's enroll/finish flow and the web's Approve stay one protocol.",
+ "name": "membership: a member self-enrols from their handoff (dropping it), an insider gives a newcomer access, they self-enrol too",
+ "note": "the whole no-request flow β€” lock hands James a key, James turns it into a durable keychain wrap (his `member` handoff drops), Paul gives Carol (who joined later) a handoff, Carol self-enrols a passkey (her handoff drops). enroll drops the enroller's `member` handoff by uid.",
"event_sequence": [
- { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
- { "type": "vault.request", "aid": "a2", "rid": "r1", "pub": "eph-pub", "ephWrapped": "eph-wrapped", "kind": "keychain", "label": "James β€” CLI", "uid": "u2", "name": "James", "ts": 2000 },
- { "type": "vault.grant", "aid": "a3", "rid": "r1", "granted": "granted-blob", "uid": "u1", "name": "Paul", "ts": 3000 },
- { "type": "vault.enroll", "aid": "a4", "rid": "r1", "wrap": { "id": "w2", "kind": "keychain", "label": "James β€” CLI", "wrapped": "wrap-2" }, "uid": "u2", "name": "James", "ts": 4000 }
+ { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "handoffs": [{ "id": "h2", "kind": "member", "uid": "u2", "label": "James", "wrapped": "handoff-james" }], "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
+ { "type": "vault.enroll", "aid": "a2", "wrap": { "id": "w2", "kind": "keychain", "uid": "u2", "label": "James β€” CLI", "wrapped": "wrap-2" }, "uid": "u2", "name": "James", "ts": 2000 },
+ { "type": "vault.give", "aid": "a3", "wrap": { "id": "h3", "kind": "member", "uid": "u3", "label": "Carol", "wrapped": "handoff-carol" }, "uid": "u1", "name": "Paul", "ts": 3000 },
+ { "type": "vault.enroll", "aid": "a4", "wrap": { "id": "w3", "kind": "passkey", "uid": "u3", "label": "Carol", "credId": "cred-3", "wrapped": "wrap-3" }, "uid": "u3", "name": "Carol", "ts": 4000 }
],
"expected_state": {
"items": [],
"log": [
{ "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 },
- { "id": "a2", "ref": "", "action": "request", "name": "James β€” CLI", "by": "u2", "byName": "James", "at": 2000 },
- { "id": "a3", "ref": "", "action": "grant", "name": "James β€” CLI", "by": "u1", "byName": "Paul", "at": 3000 },
- { "id": "a4", "ref": "", "action": "enroll", "name": "James β€” CLI", "by": "u2", "byName": "James", "at": 4000 }
+ { "id": "a2", "ref": "", "action": "enroll", "name": "James β€” CLI", "by": "u2", "byName": "James", "at": 2000 },
+ { "id": "a3", "ref": "", "action": "give", "name": "Carol", "by": "u1", "byName": "Paul", "at": 3000 },
+ { "id": "a4", "ref": "", "action": "enroll", "name": "Carol", "by": "u3", "byName": "Carol", "at": 4000 }
],
"keyring": {
"v": 1,
"pub": "vault-pub",
"wraps": [
- { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" },
- { "id": "w2", "kind": "keychain", "label": "James β€” CLI", "credId": "", "wrapped": "wrap-2" }
- ],
- "requests": []
+ { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" },
+ { "id": "w2", "kind": "keychain", "uid": "u2", "label": "James β€” CLI", "credId": "", "wrapped": "wrap-2" },
+ { "id": "w3", "kind": "passkey", "uid": "u3", "label": "Carol", "credId": "cred-3", "wrapped": "wrap-3" }
+ ]
+ }
+ }
+ },
+ {
+ "name": "enroll drops only the enroller's own handoff, never another member's",
+ "note": "lock hands James (u2) and Carol (u3) a key each; James enrolling drops h2 only β€” Carol's h3 stays until she enrols.",
+ "event_sequence": [
+ { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "handoffs": [{ "id": "h2", "kind": "member", "uid": "u2", "label": "James", "wrapped": "hj" }, { "id": "h3", "kind": "member", "uid": "u3", "label": "Carol", "wrapped": "hc" }], "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
+ { "type": "vault.enroll", "aid": "a2", "wrap": { "id": "w2", "kind": "passkey", "uid": "u2", "label": "James", "credId": "cred-2", "wrapped": "wrap-2" }, "uid": "u2", "name": "James", "ts": 2000 }
+ ],
+ "expected_state": {
+ "items": [],
+ "log": [
+ { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 },
+ { "id": "a2", "ref": "", "action": "enroll", "name": "James", "by": "u2", "byName": "James", "at": 2000 }
+ ],
+ "keyring": {
+ "v": 1,
+ "pub": "vault-pub",
+ "wraps": [
+ { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" },
+ { "id": "h3", "kind": "member", "uid": "u3", "label": "Carol", "credId": "", "wrapped": "hc" },
+ { "id": "w2", "kind": "passkey", "uid": "u2", "label": "James", "credId": "cred-2", "wrapped": "wrap-2" }
+ ]
}
}
},
{
"name": "revoke drops a wrap but never the last one",
"event_sequence": [
- { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
+ { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
{ "type": "vault.revoke", "aid": "a2", "wrapId": "w1", "uid": "u1", "name": "Paul", "ts": 2000 },
- { "type": "vault.enroll", "aid": "a3", "wrap": { "id": "w2", "kind": "keychain", "label": "Paul β€” CLI", "wrapped": "wrap-2" }, "uid": "u1", "name": "Paul", "ts": 3000 },
+ { "type": "vault.enroll", "aid": "a3", "wrap": { "id": "w2", "kind": "keychain", "uid": "u1", "label": "Paul β€” CLI", "wrapped": "wrap-2" }, "uid": "u1", "name": "Paul", "ts": 3000 },
{ "type": "vault.revoke", "aid": "a4", "wrapId": "w1", "uid": "u1", "name": "Paul", "ts": 4000 }
],
"expected_state": {
@@ -160,8 +190,7 @@
"keyring": {
"v": 1,
"pub": "vault-pub",
- "wraps": [{ "id": "w2", "kind": "keychain", "label": "Paul β€” CLI", "credId": "", "wrapped": "wrap-2" }],
- "requests": []
+ "wraps": [{ "id": "w2", "kind": "keychain", "uid": "u1", "label": "Paul β€” CLI", "credId": "", "wrapped": "wrap-2" }]
}
}
},
@@ -169,7 +198,7 @@
"name": "recovery wrap (#995) is accepted and un-revokable",
"note": "kind:recovery is the passphrase-derived break-glass escrowed in 1Password; the reducer must accept it (wrapShape) and refuse to revoke it even when other wraps exist β€” so a hostile session can't strip DR access.",
"event_sequence": [
- { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
+ { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
{ "type": "vault.enroll", "aid": "a2", "wrap": { "id": "rec", "kind": "recovery", "label": "Recovery key (1Password)", "wrapped": "recov-blob" }, "uid": "u1", "name": "Paul", "ts": 2000 },
{ "type": "vault.revoke", "aid": "a3", "wrapId": "rec", "uid": "u9", "name": "Mallory", "ts": 3000 }
],
@@ -183,23 +212,22 @@
"v": 1,
"pub": "vault-pub",
"wraps": [
- { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" },
- { "id": "rec", "kind": "recovery", "label": "Recovery key (1Password)", "credId": "", "wrapped": "recov-blob" }
- ],
- "requests": []
+ { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" },
+ { "id": "rec", "kind": "recovery", "label": "Recovery key (1Password)", "uid": "", "credId": "", "wrapped": "recov-blob" }
+ ]
}
}
},
{
- "name": "junk requests/grants/enrolls fold to no-ops on a locked vault",
+ "name": "junk gives/enrolls fold to no-ops on a locked vault",
+ "note": "a give must be a well-formed `member` wrap WITH a uid; an enroll wrap must be durable (never `member`); malformed wraps are rejected by wrapShape.",
"event_sequence": [
- { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
- { "type": "vault.request", "aid": "b1", "rid": "r1", "pub": "p", "ephWrapped": "e", "kind": "usb-fob" },
- { "type": "vault.request", "aid": "b2", "rid": "r2", "pub": "", "ephWrapped": "e", "kind": "passkey" },
- { "type": "vault.request", "aid": "b3", "rid": "r3", "pub": "p", "kind": "passkey" },
- { "type": "vault.grant", "aid": "b4", "rid": "ghost", "granted": "blob" },
- { "type": "vault.grant", "aid": "b5", "rid": "r1", "granted": "" },
- { "type": "vault.enroll", "aid": "b6", "wrap": { "id": "", "kind": "passkey", "wrapped": "x" } }
+ { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
+ { "type": "vault.give", "aid": "b1", "wrap": { "id": "g1", "kind": "member", "wrapped": "x" } },
+ { "type": "vault.give", "aid": "b2", "wrap": { "id": "g2", "kind": "passkey", "uid": "u2", "wrapped": "x" } },
+ { "type": "vault.give", "aid": "b3", "wrap": { "id": "", "kind": "member", "uid": "u2", "wrapped": "x" } },
+ { "type": "vault.enroll", "aid": "b4", "wrap": { "id": "", "kind": "passkey", "wrapped": "x" } },
+ { "type": "vault.enroll", "aid": "b5", "wrap": { "id": "w9", "kind": "member", "uid": "u2", "wrapped": "x" } }
],
"expected_state": {
"items": [],
@@ -209,8 +237,7 @@
"keyring": {
"v": 1,
"pub": "vault-pub",
- "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }],
- "requests": []
+ "wraps": [{ "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }]
}
}
},
@@ -218,9 +245,8 @@
"name": "keyring events before any lock fold to no-ops",
"event_sequence": [
{ "type": "vault.enroll", "aid": "a1", "wrap": { "id": "w1", "kind": "passkey", "wrapped": "x" } },
- { "type": "vault.request", "aid": "a2", "rid": "r1", "pub": "p", "ephWrapped": "e", "kind": "passkey" },
- { "type": "vault.grant", "aid": "a3", "rid": "r1", "granted": "g" },
- { "type": "vault.revoke", "aid": "a4", "wrapId": "w1" }
+ { "type": "vault.give", "aid": "a2", "wrap": { "id": "g1", "kind": "member", "uid": "u2", "wrapped": "x" } },
+ { "type": "vault.revoke", "aid": "a3", "wrapId": "w1" }
],
"expected_state": { "items": [], "log": [], "keyring": null }
},
@@ -228,7 +254,7 @@
"name": "sealed add on a locked vault; a change carrying sealed replaces stale plaintext",
"event_sequence": [
{ "type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000 },
- { "type": "vault.lock", "aid": "a2", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 2000 },
+ { "type": "vault.lock", "aid": "a2", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 2000 },
{ "type": "vault.add", "id": "s2", "aid": "a3", "label": "Token", "sealed": "sealed-s2", "uid": "u2", "name": "James", "ts": 3000 },
{ "type": "vault.change", "id": "s1", "aid": "a4", "sealed": "sealed-s1-now", "uid": "u2", "name": "James", "ts": 4000 }
],
@@ -246,32 +272,7 @@
"keyring": {
"v": 1,
"pub": "vault-pub",
- "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }],
- "requests": []
- }
- }
- },
- {
- "name": "a pending request folds with its actor and survives until granted+enrolled",
- "event_sequence": [
- { "type": "vault.lock", "aid": "a1", "pub": "vault-pub", "wrap": { "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }, "sealed": {}, "uid": "u1", "name": "Paul", "ts": 1000 },
- { "type": "vault.request", "aid": "a2", "rid": "r1", "pub": "eph-pub", "ephWrapped": "eph-wrapped", "kind": "passkey", "credId": "cred-2", "label": "James", "uid": "u2", "name": "James", "ts": 2000 },
- { "type": "vault.grant", "aid": "a3", "rid": "r1", "granted": "granted-blob", "uid": "u1", "name": "Paul", "ts": 3000 }
- ],
- "expected_state": {
- "items": [],
- "log": [
- { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 },
- { "id": "a2", "ref": "", "action": "request", "name": "James", "by": "u2", "byName": "James", "at": 2000 },
- { "id": "a3", "ref": "", "action": "grant", "name": "James", "by": "u1", "byName": "Paul", "at": 3000 }
- ],
- "keyring": {
- "v": 1,
- "pub": "vault-pub",
- "wraps": [{ "id": "w1", "kind": "passkey", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }],
- "requests": [
- { "id": "r1", "pub": "eph-pub", "ephWrapped": "eph-wrapped", "kind": "passkey", "credId": "cred-2", "label": "James", "granted": "granted-blob", "by": "u2", "byName": "James", "at": 2000 }
- ]
+ "wraps": [{ "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }]
}
}
}
diff --git a/test/vaultcrypto.test.mjs b/test/vaultcrypto.test.mjs
index 910b91f..a40bdb7 100644
--- a/test/vaultcrypto.test.mjs
+++ b/test/vaultcrypto.test.mjs
@@ -18,8 +18,8 @@ import {
unwrapVaultPriv,
sealValue,
unsealValue,
- grantVaultKey,
- acceptGrant,
+ sealVaultKeyTo,
+ openVaultHandoff,
wrapVaultPrivRecovery,
unwrapVaultPrivRecovery,
} from "../web/static/vaultcrypto.js";
@@ -79,36 +79,32 @@ await test("importKEK: raw 32 bytes (the CLI keychain shape) drive the same wrap
assert.equal(await unwrapVaultPriv(wrapped, await importKEK(raw)), kp.privateKey);
});
-await test("the full grant ceremony: lock β†’ request β†’ grant β†’ finish β†’ unseal", async () => {
+await test("the membership handoff: lock hands the key to a member's roster key β†’ they self-enrol β†’ both unseal", async () => {
// Paul locks: vault keypair, his wrap under his (synthetic) PRF KEK.
const paulKek = await kekFromPRF(prf());
const vault = await generateVaultKeypair();
const paulWrap = await wrapVaultPriv(vault.privateKey, paulKek);
const sealed = await sealValue(vault.publicKey, "LCHAT_ADMIN_TOKEN=abc123");
- // James requests: his own KEK, an ephemeral keypair, its private half sealed
- // under HIS kek (this is `ephWrapped` riding inside the request event).
- const jamesKek = await kekFromPRF(prf());
- const eph = await generateVaultKeypair();
- const ephWrapped = await wrapVaultPriv(eph.privateKey, jamesKek);
-
- // Paul approves: unlock (his kek β†’ vault priv), wrap the vault key to the
- // request's ephemeral public key (this is `granted` in vault.grant).
- const paulPriv = await unwrapVaultPriv(paulWrap, paulKek);
- const granted = await grantVaultKey(paulPriv, eph.publicKey);
+ // The handoff: Paul seals the vault PRIVATE key to James's ROSTER public key
+ // (this is the `member` wrap's `wrapped`, ridden by vault.lock's handoffs / a
+ // vault.give). James holds a normal user keypair β€” his identity, not a vault key.
+ const james = await generateVaultKeypair();
+ const handoff = await sealVaultKeyTo(james.publicKey, vault.privateKey);
- // James finishes: his kek opens ephWrapped, the ephemeral key opens the
- // grant, and his own durable wrap goes into the keyring.
- const ephPriv = await unwrapVaultPriv(ephWrapped, jamesKek);
- const jamesPriv = await acceptGrant(granted, ephPriv);
- const jamesWrap = await wrapVaultPriv(jamesPriv, jamesKek);
+ // James self-enrols: open the handoff with HIS session private key (no
+ // biometric β€” it's sealed to his identity), then wrap the vault key under his
+ // OWN passkey KEK. From here on he needs his passkey.
+ const vaultPrivForJames = await openVaultHandoff(handoff, james.privateKey);
+ const jamesKek = await kekFromPRF(prf());
+ const jamesWrap = await wrapVaultPriv(vaultPrivForJames, jamesKek);
// Both members now unseal the same value through their own biometric-gated wrap.
assert.equal(await unsealValue(sealed, await unwrapVaultPriv(paulWrap, paulKek)), "LCHAT_ADMIN_TOKEN=abc123");
assert.equal(await unsealValue(sealed, await unwrapVaultPriv(jamesWrap, jamesKek)), "LCHAT_ADMIN_TOKEN=abc123");
- // …and a third party holding only the grant (no ephemeral key) gets nothing.
+ // …and a third party (not the handoff's recipient) can't open it.
const stranger = await generateVaultKeypair();
- await assert.rejects(() => acceptGrant(granted, stranger.privateKey));
+ await assert.rejects(() => openVaultHandoff(handoff, stranger.privateKey));
});
// ---- πŸ€– the recovery wrap (#995) β€” passphrase break-glass ---------------------
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js
index a3c043f..74f5475 100644
--- a/web/static/apps/vault.js
+++ b/web/static/apps/vault.js
@@ -43,10 +43,13 @@
// thread can still ADD a secret without an unlock (encrypting to a public key
// is free), but READING a value takes a fresh biometric. A signed-in session
// alone β€” thread key in hand β€” sees names and the audit trail, never values.
-// Access moves by request/grant events: a member without a wrap publishes an
-// ephemeral public key; any unlocked member wraps the vault key to it; the
-// requester finishes by enrolling a durable wrap of their own. All of it is
-// ordinary events through the same reducer β€” malformed folds to a no-op, so
+// Access follows THREAD MEMBERSHIP, not a request dance: locking HANDS the vault
+// key to each current member (sealed to their roster key β€” a `member` handoff),
+// so every member is entitled by being in the thread. Each member self-enrols
+// their own passkey (vault.enroll), and their handoff drops. An already-unlocked
+// insider can give a later joiner a fresh handoff (vault.give). There is no
+// "request access" β€” you never ask for a key to a vault you're already in. All of
+// it is ordinary events through the same reducer β€” malformed folds to a no-op, so
// hostile inputs stay safe, which `groupCapable` requires. The crypto lives in
// vaultcrypto.js; reducers here never touch it (folds stay pure and sync).
//
@@ -67,8 +70,7 @@ import {
unwrapVaultPriv,
sealValue,
unsealValue,
- grantVaultKey,
- acceptGrant,
+ sealVaultKeyTo,
} from "../vaultcrypto.js";
// How long a revealed value stays visible before it re-locks itself β€” short, so a
@@ -102,35 +104,33 @@ function findByName(items, name) {
return (items || []).find((it) => (it.name || "").toLowerCase() === n) || null;
}
-// πŸ€– wrapShape / requestShape vet keyring objects arriving in events before the
-// reducer stores them β€” every field a later unlock will read must be a
-// non-empty string, and anything extra is dropped (never store a stranger's
-// spare fields). Return the cleaned object, or null. Pure; exported for tests.
+// πŸ€– wrapShape vets keyring objects arriving in events before the reducer stores
+// them β€” every field a later unlock will read must be a non-empty string, and
+// anything extra is dropped (never store a stranger's spare fields). Return the
+// cleaned object, or null. Pure; exported for tests.
+// Wrap kinds (docs/locked-vaults.md):
+// passkey / keychain β€” a member's DURABLE biometric wrap (WebAuthn PRF KEK /
+// CLI Touch ID keychain KEK). "Requires a passkey to open."
+// recovery (#995) β€” the passphrase-derived break-glass, escrowed in
+// 1Password; un-revokable.
+// member β€” a HANDOFF: the vault key sealed to a member's roster
+// public key, so that member can SELF-ENROL a passkey without anyone
+// approving. Soft (their session key opens it); dropped the moment they
+// enrol their own biometric wrap. Carries `uid` = whose handoff it is.
+// Every wrap carries `uid` (the member it belongs to) so the reducer can drop a
+// member's handoff when they enrol, and the UI can tell whose key is whose.
export function wrapShape(w) {
if (!w || typeof w.id !== "string" || !w.id) return null;
- // "recovery" (#995) = a passphrase-derived wrap, escrowed in 1Password β€” the
- // any-machine break-glass; the reducer keeps it un-revokable (see vault.revoke).
- if (w.kind !== "passkey" && w.kind !== "keychain" && w.kind !== "recovery") return null;
+ if (w.kind !== "passkey" && w.kind !== "keychain" && w.kind !== "recovery" && w.kind !== "member") return null;
if (typeof w.wrapped !== "string" || !w.wrapped) return null;
- const out = { id: w.id, kind: w.kind, wrapped: w.wrapped, label: typeof w.label === "string" ? w.label : "" };
- if (typeof w.credId === "string" && w.credId) out.credId = w.credId;
- return out;
-}
-
-export function requestShape(e) {
- if (typeof e.rid !== "string" || !e.rid) return null;
- if (typeof e.pub !== "string" || !e.pub) return null;
- if (typeof e.ephWrapped !== "string" || !e.ephWrapped) return null;
- if (e.kind !== "passkey" && e.kind !== "keychain") return null;
const out = {
- id: e.rid, // addItem/updateItem/removeItem key on `id`, so the request id IS its id
- pub: e.pub,
- ephWrapped: e.ephWrapped,
- kind: e.kind,
- label: typeof e.label === "string" ? e.label : "",
- granted: "",
+ id: w.id,
+ kind: w.kind,
+ uid: typeof w.uid === "string" ? w.uid : "",
+ wrapped: w.wrapped,
+ label: typeof w.label === "string" ? w.label : "",
};
- if (typeof e.credId === "string" && e.credId) out.credId = e.credId;
+ if (typeof w.credId === "string" && w.credId) out.credId = w.credId;
return out;
}
@@ -139,10 +139,9 @@ export function requestShape(e) {
// β€” exactly one of value (plain vault) / sealed (locked vault: ECIES to the
// vault public key, b64url ephPub||iv||ct; see vaultcrypto.js).
// log: { id (audit id), ref (item id), action, name, by, byName, at }
-// β€” action: add | change | remove | lock | enroll | revoke | request | grant
-// keyring (locked vaults only): { v: 1, pub, wraps: [wrapShape],
-// requests: [requestShape + { by, byName, at }] } β€” see the lock-layer note
-// at the top of this file and docs/locked-vaults.md.
+// β€” action: add | change | remove | lock | enroll | give | revoke
+// keyring (locked vaults only): { v: 1, pub, wraps: [wrapShape] } β€” see the
+// lock-layer note at the top of this file and docs/locked-vaults.md.
//
// The secret's name rides in the event as `label`, NOT `name` β€” the emit
// transport OVERWRITES `event.name` with the sender's screen name (that's how the
@@ -214,10 +213,13 @@ export const app = defineApp({
// ---- πŸ€– the lock layer (docs/locked-vaults.md) --------------------------
- // vault.lock installs the keyring: the vault public key, the locker's first
- // wrap, and a re-seal of every existing value (item id β†’ sealed blob,
- // produced client-side by the locker, who could read them). One-way: a
- // vault with a keyring never accepts another lock.
+ // vault.lock turns a shared vault passkey-locked: installs the vault public
+ // key, seals every value to it, adds the LOCKER's own biometric wrap, and β€”
+ // so nobody has to "request" anything β€” HANDS the vault key to each other
+ // current member by sealing it to their roster key (`handoffs`, each a
+ // `member` wrap). Every member is entitled by membership; each self-enrols
+ // their own passkey next (vault.enroll), and their handoff drops then.
+ // One-way: a keyring is never replaced.
"vault.lock": (s, e) => {
if (s.keyring) return s;
const wrap = wrapShape(e.wrap);
@@ -229,36 +231,53 @@ export const app = defineApp({
const { value, ...rest } = it;
return { ...rest, sealed: blob };
});
- return {
- items,
- log: logEntry(s.log, e, "lock", ""),
- keyring: { v: 1, pub: e.pub, wraps: [wrap], requests: [] },
- };
+ let wraps = [wrap];
+ for (const h of Array.isArray(e.handoffs) ? e.handoffs : []) {
+ const hw = wrapShape(h);
+ if (hw && hw.kind === "member") wraps = addItem(wraps, hw);
+ }
+ return { items, log: logEntry(s.log, e, "lock", ""), keyring: { v: 1, pub: e.pub, wraps } };
},
- // vault.enroll adds a wrap β€” a new device/passkey of an unlocked member, or
- // a requester completing a grant (rid clears their pending request).
+ // vault.enroll installs a member's DURABLE biometric wrap (passkey/keychain)
+ // and DROPS that member's soft `member` handoff β€” from here on they need
+ // their passkey. `uid` says whose it is; a member self-enrols with no
+ // approval (they already hold the key by membership).
"vault.enroll": (s, e) => {
const wrap = wrapShape(e.wrap);
- if (!s.keyring || !e.aid || !wrap) return s;
- const requests = typeof e.rid === "string" && e.rid ? removeItem(s.keyring.requests, e.rid) : s.keyring.requests;
+ if (!s.keyring || !e.aid || !wrap || wrap.kind === "member") return s;
+ const uid = wrap.uid || e.uid || "";
+ const wraps = (s.keyring.wraps || []).filter((w) => !(w.kind === "member" && uid && w.uid === uid));
return {
...s,
log: logEntry(s.log, e, "enroll", wrap.label),
- keyring: { ...s.keyring, wraps: addItem(s.keyring.wraps, wrap), requests },
+ keyring: { ...s.keyring, wraps: addItem(wraps, wrap) },
+ };
+ },
+
+ // vault.give: an already-unlocked member admits someone who wasn't present at
+ // lock time (or lost every device) β€” seals the vault key to that member's
+ // roster key as a `member` handoff, which they self-enrol from. The ONLY
+ // deliberate hand-off, and it's an insider GIVING access, never an outsider
+ // "requesting" it. `uid` is the recipient.
+ "vault.give": (s, e) => {
+ const wrap = wrapShape(e.wrap);
+ if (!s.keyring || !e.aid || !wrap || wrap.kind !== "member" || !wrap.uid) return s;
+ return {
+ ...s,
+ log: logEntry(s.log, e, "give", wrap.label),
+ keyring: { ...s.keyring, wraps: addItem(s.keyring.wraps, wrap) },
};
},
// vault.revoke drops a wrap β€” housekeeping, NOT crypto revocation (an
// ex-holder saw the values; rotating the real secrets is the honest move,
- // docs/locked-vaults.md "What this does not do"). The LAST wrap can never
- // be dropped: that would strand the vault with no openable key, forever.
+ // docs/locked-vaults.md "What this does not do"). The LAST wrap can never be
+ // dropped, and a recovery wrap (#995) is un-revokable β€” the escrowed
+ // break-glass a hostile session must not be able to strip.
"vault.revoke": (s, e) => {
if (!s.keyring || !e.aid || typeof e.wrapId !== "string" || !e.wrapId) return s;
const gone = (s.keyring.wraps || []).find((w) => w.id === e.wrapId);
- // The last wrap can't go, and a recovery wrap (#995) can NEVER be revoked:
- // it's the escrowed break-glass, so a hostile session must not be able to
- // strip it and lock everyone β€” including DR β€” out of the vault.
if (!gone || gone.kind === "recovery" || s.keyring.wraps.length <= 1) return s;
return {
...s,
@@ -266,37 +285,6 @@ export const app = defineApp({
keyring: { ...s.keyring, wraps: removeItem(s.keyring.wraps, e.wrapId) },
};
},
-
- // vault.request: a member with no wrap asks in β€” an ephemeral public key to
- // grant to, plus their own ephemeral private half sealed under their own
- // biometric KEK (ephWrapped), so the pending request survives reloads yet
- // only they can complete it.
- "vault.request": (s, e) => {
- const req = requestShape(e);
- if (!s.keyring || !e.aid || !req) return s;
- return {
- ...s,
- log: logEntry(s.log, e, "request", req.label),
- keyring: {
- ...s.keyring,
- requests: addItem(s.keyring.requests, { ...req, by: e.uid || "", byName: e.name || "", at: e.ts || 0 }),
- },
- };
- },
-
- // vault.grant: an unlocked member wrapped the vault key to a request's
- // ephemeral public key. A bogus blob just fails to open at finish time β€”
- // folding it is safe, and the audit trail says who posted it.
- "vault.grant": (s, e) => {
- if (!s.keyring || !e.aid || typeof e.rid !== "string" || typeof e.granted !== "string" || !e.granted) return s;
- const req = (s.keyring.requests || []).find((r) => r.id === e.rid);
- if (!req) return s;
- return {
- ...s,
- log: logEntry(s.log, e, "grant", req.label),
- keyring: { ...s.keyring, requests: updateItem(s.keyring.requests, e.rid, { granted: e.granted }) },
- };
- },
},
commands: {
// /vault <name> = <value> β€” familiar first: a Slack/Discord-style command,
@@ -349,8 +337,6 @@ export const app = defineApp({
if (e.type === "vault.remove") return `πŸ”’ ${who} removed a secret from the Vault`;
if (e.type === "vault.change") return `πŸ”’ ${who} updated a secret in the Vault`;
if (e.type === "vault.lock") return `πŸ”’ ${who} locked the Vault`;
- if (e.type === "vault.request") return `πŸ”’ ${who} requested access to the Vault`;
- if (e.type === "vault.grant") return `πŸ”’ ${who} granted access to the Vault`;
return null;
},
@@ -457,7 +443,7 @@ export const app = defineApp({
const wrap = document.createElement("div");
wrap.className = "vault-wrap";
- // One inline status line for the whole surface β€” the lock/unlock/grant
+ // One inline status line for the whole surface β€” the lock/unlock/enrol
// ceremonies report here. In-flow (occupies layout space), never a
// floating toast; role=status so screen readers announce it.
const note = document.createElement("div");
@@ -487,15 +473,31 @@ export const app = defineApp({
// leaves the handler β€” adding never costs an unlock.
const form = document.createElement("form");
form.className = "vault-add";
+ // Keep password managers OUT of the add form. A "Name" text field beside a
+ // type=password field reads to 1Password/Chrome/etc. as a username+password
+ // LOGIN, so they offer to "save" the secret you're storing β€” clutter, and
+ // (Paul's report) their save popup reads like a passkey prompt. These are the
+ // managers' documented opt-outs, plus declaring the whole form non-login so no
+ // credential pair is inferred. A vault secret is content, never a saved login.
+ const noPwManager = (el) => {
+ el.autocomplete = "off";
+ el.setAttribute("data-1p-ignore", ""); // 1Password
+ el.setAttribute("data-lpignore", "true"); // LastPass
+ el.setAttribute("data-bwignore", ""); // Bitwarden
+ el.setAttribute("data-form-type", "other"); // Dashlane + a 1Password hint
+ };
+ form.setAttribute("data-form-type", "other");
+ form.autocomplete = "off";
const nameIn = document.createElement("input");
nameIn.className = "vault-name";
nameIn.placeholder = "Name";
nameIn.maxLength = 80;
+ noPwManager(nameIn);
const valIn = document.createElement("input");
valIn.className = "vault-val";
valIn.type = "password"; // masked while typing β€” this is a secret going in
valIn.placeholder = "Secret value";
- valIn.autocomplete = "off";
+ noPwManager(valIn);
const add = document.createElement("button");
add.type = "submit";
add.className = "vault-add-btn";
@@ -525,7 +527,10 @@ export const app = defineApp({
wrap.appendChild(list);
}
- if (keyring) wrap.appendChild(buildAccess(keyring, ctx, say));
+ if (keyring) {
+ const access = buildAccess(keyring, ctx, say);
+ if (access) wrap.appendChild(access); // null for a personal vault (no sharing surface)
+ }
if (log.length) wrap.appendChild(buildHistory(log, ctx));
el.appendChild(wrap);
@@ -613,7 +618,7 @@ async function ensureUnlocked(ctx, keyring, say) {
if (cached) return cached;
const passkeys = (keyring.wraps || []).filter((w) => w.kind === "passkey" && w.credId);
if (!passkeys.length) {
- say("Only CLI keys are enrolled in this vault β€” unlock isn't possible in the browser yet. Request access below.");
+ say("Only CLI keys are enrolled in this vault β€” unlock isn't possible in the browser yet. Set up your passkey below.");
return null;
}
let res;
@@ -627,7 +632,7 @@ async function ensureUnlocked(ctx, keyring, say) {
}
const wrap = passkeys.find((w) => w.credId === res.credId);
if (!wrap) {
- say("That passkey isn't enrolled in this vault β€” request access below.");
+ say("That passkey isn't enrolled in this vault β€” set up your passkey below.");
return null;
}
try {
@@ -636,7 +641,7 @@ async function ensureUnlocked(ctx, keyring, say) {
cacheUnlock(ctx.threadId, priv);
return priv;
} catch {
- say("Couldn't open the vault with that passkey β€” its enrolment may be stale. Request access below.");
+ say("Couldn't open the vault with that passkey β€” its enrolment may be stale. Set up your passkey below.");
return null;
}
}
@@ -658,8 +663,10 @@ async function addOrUpdate(ctx, name, value) {
// doLock runs the whole lock ceremony: mint a passkey (verifying PRF end-to-end
// BEFORE anything posts β€” the no-prf lesson from home), mint the vault keypair,
-// re-seal every existing value, emit one vault.lock. The locker ends up
-// unlocked, so their next reveal is free.
+// re-seal every existing value, and HAND the vault key to every other current
+// member (sealed to their roster key β€” a `member` handoff), so each can set up
+// their own passkey without anyone approving. One vault.lock carries it all. The
+// locker ends up unlocked, so their next reveal is free.
async function doLock(ctx, say) {
let pk;
try {
@@ -675,11 +682,19 @@ async function doLock(ctx, say) {
for (const it of state.items || []) {
if (typeof it.value === "string") sealed[it.id] = await sealValue(kp.publicKey, it.value);
}
+ // A handoff per other member: the vault key sealed to their roster key. The
+ // host excludes me and bots; a personal vault has no other members β†’ none.
+ const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(ctx.threadId) : [];
+ const handoffs = [];
+ for (const m of members) {
+ handoffs.push({ id: ctx.id(), kind: "member", uid: m.uid, label: m.name || "", wrapped: await sealVaultKeyTo(m.pub, kp.privateKey) });
+ }
ctx.emit({
type: "vault.lock",
aid: ctx.id(),
pub: kp.publicKey,
- wrap: { id: ctx.id(), kind: "passkey", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(kp.privateKey, kek) },
+ wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(kp.privateKey, kek) },
+ handoffs,
sealed,
});
// Slam every on-screen reveal shut the instant we lock β€” don't wait for the
@@ -687,75 +702,85 @@ async function doLock(ctx, say) {
// material). cacheUnlock keeps the locker able to re-reveal with no biometric.
relockOpenReveals();
cacheUnlock(ctx.threadId, kp.privateKey);
- say("Vault locked β€” values now need a passkey to read.");
+ say(
+ handoffs.length
+ ? `Vault locked β€” values now need a passkey to read. ${handoffs.length === 1 ? "1 member" : handoffs.length.toLocaleString() + " members"} can set up their own passkey.`
+ : "Vault locked β€” values now need a passkey to read."
+ );
}
-// doRequest: a member with no wrap mints their own passkey + an ephemeral
-// keypair and asks in. Their ephemeral private half rides the request sealed
-// under their own KEK, so only they can complete the grant β€” from any device
-// holding the passkey.
-async function doRequest(ctx, say) {
+// doEnrolMe: a member who holds a `member` handoff (the vault key sealed to them
+// at lock, or by a give) turns it into a durable passkey wrap. Open the handoff
+// with my session key (host.openMine), verify it actually opens the vault, mint
+// a passkey, wrap the vault key under it, emit vault.enroll β€” which DROPS my
+// handoff, so from here on I need my passkey. No approval: I already hold the
+// key by membership.
+async function doEnrolMe(ctx, keyring, say) {
+ const mine = (keyring.wraps || []).find((w) => w.kind === "member" && w.uid === ctx.me);
+ if (!mine) {
+ say("You don't have a key for this vault yet β€” ask someone already in to give you access.");
+ return;
+ }
+ if (!ctx.host || !ctx.host.vault) {
+ say("Setting up a vault passkey isn't supported here yet.");
+ return;
+ }
+ const opened = await ctx.host.vault.openMine(mine.wrapped);
+ const vaultPriv = opened && typeof opened.k === "string" ? opened.k : null;
+ if (!vaultPriv) {
+ say("Couldn't open your vault key β€” ask someone in the vault to give you access again.");
+ return;
+ }
+ // Integrity: the handoff key must actually open this vault. If any value is
+ // sealed, it has to decrypt β€” a mismatched or hostile handoff fails HERE,
+ // before it gets wrapped under a passkey. An empty vault has nothing to check.
+ const sample = ((ctx.getState() || {}).items || []).find((it) => typeof it.sealed === "string" && it.sealed);
+ if (sample) {
+ try {
+ await unsealValue(sample.sealed, vaultPriv);
+ } catch {
+ say("The key you were given doesn't match this vault β€” ask for access again.");
+ return;
+ }
+ }
let pk;
try {
pk = await registerVaultPasskey(`Vault β€” ${ctx.meName || "me"}`);
} catch (e) {
- say(passkeyError(e, "Request"));
+ say(passkeyError(e, "Set up"));
return;
}
const kek = await kekFromPRF(pk.prf);
- const eph = await generateVaultKeypair();
ctx.emit({
- type: "vault.request",
+ type: "vault.enroll",
aid: ctx.id(),
- rid: ctx.id(),
- pub: eph.publicKey,
- ephWrapped: await wrapVaultPriv(eph.privateKey, kek),
- kind: "passkey",
- credId: pk.credId,
- label: ctx.meName || "",
+ wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(vaultPriv, kek) },
});
- say("Access requested β€” anyone who can unlock the vault can approve it.");
+ cacheUnlock(ctx.threadId, vaultPriv);
+ say("You're set β€” this vault now opens with your passkey.");
}
-// doGrant: an unlocked member approves a request β€” one biometric (the unlock),
-// then the vault key wraps to the request's ephemeral public key.
-async function doGrant(ctx, keyring, req, say) {
+// doGive: an unlocked member admits someone in the thread who has no key yet β€”
+// seal the vault key to that member's roster key as a `member` handoff, which
+// they self-enrol from. One biometric (the unlock). The deliberate hand-off, and
+// it's an insider GIVING, never an outsider requesting.
+async function doGive(ctx, keyring, member, say) {
const priv = await ensureUnlocked(ctx, keyring, say);
if (!priv) return;
- ctx.emit({ type: "vault.grant", aid: ctx.id(), rid: req.id, granted: await grantVaultKey(priv, req.pub) });
- say(`Granted β€” ${req.byName || "they"} can finish on their side.`);
-}
-
-// doFinish: the requester completes a grant β€” one biometric opens their
-// ephemeral key, which opens the grant, and a durable wrap of their own goes
-// into the keyring (rid clears the pending request).
-async function doFinish(ctx, req, say) {
- let res;
- try {
- res = await assertVaultPasskeyFull(req.credId ? [req.credId] : []);
- } catch (e) {
- say(passkeyError(e, "Finish"));
+ if (!ctx.host || !ctx.host.vault) {
+ say("Giving access isn't supported here yet.");
return;
}
- try {
- const kek = await kekFromPRF(res.prf);
- const ephPriv = await unwrapVaultPriv(req.ephWrapped, kek);
- const priv = await acceptGrant(req.granted, ephPriv);
- ctx.emit({
- type: "vault.enroll",
- aid: ctx.id(),
- rid: req.id,
- wrap: { id: ctx.id(), kind: "passkey", label: req.label, credId: req.credId, wrapped: await wrapVaultPriv(priv, kek) },
- });
- cacheUnlock(ctx.threadId, priv);
- say("You're in β€” this vault now opens with your passkey.");
- } catch {
- say("Couldn't finish β€” the grant doesn't match this passkey's request.");
- }
+ ctx.emit({
+ type: "vault.give",
+ aid: ctx.id(),
+ wrap: { id: ctx.id(), kind: "member", uid: member.uid, label: member.name || "", wrapped: await sealVaultKeyTo(member.pub, priv) },
+ });
+ say(`${member.name || "They"} can now set up their passkey.`);
}
// doEnrollHere: an already-enrolled member adds another passkey (a new browser
-// or device) β€” unlock, mint, wrap, enroll.
+// or device whose passkeys don't sync) β€” unlock, mint, wrap, enroll.
async function doEnrollHere(ctx, keyring, say) {
const priv = await ensureUnlocked(ctx, keyring, say);
if (!priv) return;
@@ -770,7 +795,7 @@ async function doEnrollHere(ctx, keyring, say) {
ctx.emit({
type: "vault.enroll",
aid: ctx.id(),
- wrap: { id: ctx.id(), kind: "passkey", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(priv, kek) },
+ wrap: { id: ctx.id(), kind: "passkey", uid: ctx.me || "", label: ctx.meName || "", credId: pk.credId, wrapped: await wrapVaultPriv(priv, kek) },
});
say("Passkey added β€” this device can now unlock the vault.");
}
@@ -1023,30 +1048,46 @@ function auditLine(item) {
const historyOpen = {};
const accessOpen = {};
-// πŸ€– buildAccess renders the keyring: who can unlock (the wraps), pending
-// requests (Approve for members who can unlock, Finish setup for the
-// requester), and the self-service actions β€” request access / add a passkey.
-// Collapsed by default, like History; auto-opens while requests are pending so
-// an approval is never buried.
+// πŸ€– buildAccess renders a SHARED locked vault's access: who can open it (the
+// durable wraps + any pending handoffs), the one action that's mine ("Set up
+// your passkey" if I hold a handoff, else "Add a passkey on this device"), and
+// "Give <name> access" for each thread member who has no key yet. There is NO
+// "request access" β€” membership is the entitlement. A PERSONAL vault (no other
+// members) has no sharing surface at all, so this returns null there and the
+// header lock chip is the whole story. Collapsed by default; auto-opens while I
+// have a handoff to set up, or someone's waiting to be given access.
function buildAccess(keyring, ctx, say) {
const tid = ctx.threadId;
+ const wraps = keyring.wraps || [];
+ const members = ctx.host && ctx.host.vault ? ctx.host.vault.members(tid) : [];
+
+ // Who I am to this vault: a durable holder, a pending handoff, or neither.
+ const iAmEnrolled = wraps.some((w) => (w.kind === "passkey" || w.kind === "keychain") && w.uid === ctx.me);
+ const myHandoff = wraps.find((w) => w.kind === "member" && w.uid === ctx.me);
+ // Thread members with no key of any kind β€” an insider can give them one.
+ const held = new Set(wraps.map((w) => w.uid).filter(Boolean));
+ const missing = members.filter((m) => !held.has(m.uid));
+
+ // Personal vault (no other members) with nothing of mine to set up β†’ no
+ // sharing surface. The lock chip already handles lock/unlock; a lone vault
+ // never shows "access controls".
+ if (!members.length && !myHandoff && iAmEnrolled) return null;
+
const wrapEl = document.createElement("div");
wrapEl.className = "vault-access";
- const wraps = keyring.wraps || [];
- const requests = keyring.requests || [];
- const open = accessOpen[tid] !== undefined ? !!accessOpen[tid] : requests.length > 0;
+ const open = accessOpen[tid] !== undefined ? !!accessOpen[tid] : (!!myHandoff || missing.length > 0);
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "vault-access-toggle";
const keyCount = wraps.length === 1 ? "1 key" : `${wraps.length.toLocaleString()} keys`;
- const reqCount = requests.length ? (requests.length === 1 ? " Β· 1 request" : ` Β· ${requests.length.toLocaleString()} requests`) : "";
- toggle.textContent = `${open ? "β–Ύ" : "β–Έ"} Access (${keyCount}${reqCount})`;
+ toggle.textContent = `${open ? "β–Ύ" : "β–Έ"} Access (${keyCount})`;
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.onclick = () => {
accessOpen[tid] = !open;
const state = ctx.getState() || {};
- wrapEl.replaceWith(buildAccess(state.keyring || keyring, ctx, say));
+ const next = buildAccess(state.keyring || keyring, ctx, say);
+ if (next) wrapEl.replaceWith(next);
};
wrapEl.appendChild(toggle);
@@ -1059,10 +1100,20 @@ function buildAccess(keyring, ctx, say) {
const who = document.createElement("span");
who.className = "vault-access-who";
who.textContent = w.kind === "recovery" ? w.label || "Recovery key" : w.label || "someone";
- const kindLabel = w.kind === "recovery" ? " Β· recovery key (1Password)" : w.kind === "keychain" ? " Β· CLI key" : " Β· passkey";
+ // A `member` wrap is a handoff not yet turned into a passkey β€” an invite in
+ // flight, not a key that can open the vault.
+ const kindLabel =
+ w.kind === "recovery"
+ ? " Β· recovery key (1Password)"
+ : w.kind === "keychain"
+ ? " Β· CLI key"
+ : w.kind === "member"
+ ? " Β· invited β€” waiting to set up a passkey"
+ : " Β· passkey";
li.append(who, document.createTextNode(kindLabel));
- // A recovery wrap is the escrowed break-glass and is un-revokable in the
- // reducer (#995) β€” so never offer a Revoke that would silently no-op.
+ // Revoke drops a wrap (housekeeping, not crypto revocation). Never the last,
+ // never a recovery wrap (un-revokable in the reducer, #995) β€” so we never
+ // offer a Revoke that would silently no-op.
if (wraps.length > 1 && w.kind !== "recovery") {
const revoke = document.createElement("button");
revoke.type = "button";
@@ -1085,68 +1136,47 @@ function buildAccess(keyring, ctx, say) {
}
list.appendChild(li);
}
-
- for (const r of requests) {
- const li = document.createElement("li");
- const who = document.createElement("span");
- who.className = "vault-access-who";
- who.textContent = r.byName || r.label || "someone";
- // A CLI-kind request can only finish where its keychain key lives β€” the
- // browser offers Finish solely for passkey requests.
- const granted = r.granted
- ? r.kind === "keychain"
- ? " Β· granted β€” finish in the terminal (eleven vault finish)"
- : " Β· granted β€” waiting for them to finish"
- : " Β· wants access";
- li.append(who, document.createTextNode(granted));
- if (r.by === ctx.me && r.granted && r.kind === "passkey") {
- const finish = document.createElement("button");
- finish.type = "button";
- finish.className = "vault-btn";
- finish.textContent = "Finish setup";
- finish.onclick = () => doFinish(ctx, r, say);
- li.appendChild(finish);
- } else if (!r.granted) {
- const approve = document.createElement("button");
- approve.type = "button";
- approve.className = "vault-btn";
- approve.textContent = "Approve";
- approve.setAttribute("aria-label", `Approve vault access for ${r.byName || "this member"}`);
- approve.onclick = () => doGrant(ctx, keyring, r, say);
- li.appendChild(approve);
- }
- list.appendChild(li);
- }
wrapEl.appendChild(list);
- // Self-service. "Is one of these passkeys mine" can't be known for sure
- // locally (passkeys sync across a person's devices), so when passkey wraps
- // exist the emphasis is "add a passkey" (unlock first proves membership) and
- // "request access" stays reachable beside it; with no passkey wraps at all
- // (CLI-only keyring), requesting is the only path.
- const pendingMine = requests.some((r) => r.by === ctx.me);
- if (pendingMine) {
- const pending = document.createElement("button");
- pending.type = "button";
- pending.className = "vault-btn";
- pending.textContent = "Request pending…";
- pending.disabled = true;
- wrapEl.appendChild(pending);
+ // My action: set up my handoff into a passkey, or add another device's
+ // passkey once I'm already in. (Only one applies at a time.)
+ if (myHandoff && !iAmEnrolled) {
+ const setup = document.createElement("button");
+ setup.type = "button";
+ setup.className = "vault-btn";
+ setup.textContent = "Set up your passkey";
+ setup.setAttribute("aria-label", "Set up your passkey to open this vault");
+ setup.onclick = () => doEnrolMe(ctx, keyring, say);
+ wrapEl.appendChild(setup);
+ } else if (iAmEnrolled) {
+ const enroll = document.createElement("button");
+ enroll.type = "button";
+ enroll.className = "vault-btn";
+ enroll.textContent = "Add a passkey on this device";
+ enroll.onclick = () => doEnrollHere(ctx, keyring, say);
+ wrapEl.appendChild(enroll);
} else {
- if (wraps.some((w) => w.kind === "passkey")) {
- const enroll = document.createElement("button");
- enroll.type = "button";
- enroll.className = "vault-btn";
- enroll.textContent = "Add a passkey on this device";
- enroll.onclick = () => doEnrollHere(ctx, keyring, say);
- wrapEl.appendChild(enroll);
+ // A member with no key and no handoff yet β€” the honest state is "wait to be
+ // given access", never a request button.
+ const note = document.createElement("div");
+ note.className = "vault-note";
+ note.textContent = "You don't have a key yet β€” ask someone already in the vault to give you access.";
+ wrapEl.appendChild(note);
+ }
+
+ // Give access to each thread member who has no key β€” only offered to someone
+ // who can actually unlock (doGive needs the vault key). Recovery/CLI-only
+ // holders can still give from their own end.
+ if (iAmEnrolled) {
+ for (const m of missing) {
+ const give = document.createElement("button");
+ give.type = "button";
+ give.className = "vault-btn";
+ give.textContent = `Give ${m.name || "them"} access`;
+ give.setAttribute("aria-label", `Give ${m.name || "this member"} a key to this vault`);
+ give.onclick = () => doGive(ctx, keyring, m, say);
+ wrapEl.appendChild(give);
}
- const req = document.createElement("button");
- req.type = "button";
- req.className = "vault-btn";
- req.textContent = "Request access";
- req.onclick = () => doRequest(ctx, say);
- wrapEl.appendChild(req);
}
return wrapEl;
@@ -1190,9 +1220,8 @@ const LOG_VERBS = {
change: " changed ",
lock: " locked the vault",
enroll: " enrolled a key",
+ give: " gave access",
revoke: " revoked a key",
- request: " requested access",
- grant: " granted access",
};
function logRow(entry) {
diff --git a/web/static/shell.js b/web/static/shell.js
index 74d337f..07eaeeb 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -4136,6 +4136,43 @@ setHostBridge({
}
},
},
+ // The Vault app's scoped doorway to the roster + session key (docs/locked-vaults.md).
+ // A locked vault hands its key to members by SEALING it to each member's roster
+ // public key; opening that handoff needs the session PRIVATE key. Both live
+ // here, in shell.js, never in the app β€” the seam stays narrow (two calls, no
+ // raw keys ever cross it).
+ vault: {
+ // The other human members of a thread, each with their roster public key β€”
+ // so a lock (or a give) can seal the vault key to them as a `member` handoff.
+ // Excludes ME (the locker/giver already holds a durable wrap) and BOTS
+ // (is_agent): a locked vault must NEVER hand its key to a machine member,
+ // whose private key lives on a sidecar box (CLAUDE.md). Keyless members
+ // (a pending invitee) are skipped β€” an insider gives them access once they
+ // join. Names are the PUBLISHED label (rides into the emitted wrap, so it
+ // must not carry my private nickname). Empty for a personal vault β†’ the lock
+ // seals to my passkey alone. Synchronous (a plain read over in-memory state).
+ members(threadId) {
+ const t = threads.find((x) => x.id === threadId);
+ if (!t) return [];
+ return (t.members || [])
+ .filter((uid) => uid && uid !== myUserId)
+ .map((uid) => roster.find((x) => x.user_id === uid))
+ .filter((e) => e && e.public_key && !e.is_agent)
+ .map((e) => ({ uid: e.user_id, name: publishedLabel(e.user_id), pub: e.public_key }));
+ },
+ // Open a handoff (an ECIES blob sealed to MY roster public key) with my
+ // session private key β€” the vault key sealed to me at lock time, or by a
+ // give. Returns the decrypted JSON (the app reads the key out of `.k`), or
+ // null if it wasn't sealed to me / I have no key here (native embed).
+ async openMine(blob) {
+ if (!session.userPriv) return null;
+ try {
+ return await openSealed(blob, session.userPriv);
+ } catch {
+ return null;
+ }
+ },
+ },
// The /gif app's scoped Giphy doorway (apps never fetch β€” the host does, and
// only to *.giphy.com, which the CSP pins). This is the client-side sibling
// of the unfurl exception: the SERVER never sees the search, the pick, or
diff --git a/web/static/vaultcrypto.js b/web/static/vaultcrypto.js
index acfeaef..cf0c2e5 100644
--- a/web/static/vaultcrypto.js
+++ b/web/static/vaultcrypto.js
@@ -8,9 +8,9 @@
// key in hand, no biometric) can read names and the audit trail, and can add β€”
// but never read a value.
//
-// Everything here reuses crypto.js primitives β€” sealForPub/openSealed for the
-// values, wrapThreadKey/unwrapThreadKey for handing the private key around β€”
-// so the wire shapes stay the ones the Go sidecar and home already speak
+// Everything here reuses crypto.js primitives β€” sealForPub/openSealed both for
+// the values AND for handing the vault key to a member (sealed to their roster
+// key) β€” so the wire shapes stay the ones the Go sidecar and home already speak
// (ephPub(65)||iv(12)||ct, b64url iv||ct). Pure functions only; the two
// WebAuthn ceremonies touch `navigator` strictly inside their bodies so this
// module stays importable (and the pure parts testable) in Node.
@@ -22,8 +22,6 @@ import {
generateUserKeypair,
sealForPub,
openSealed,
- wrapThreadKey,
- unwrapThreadKey,
} from "./crypto.js";
// ---- PRF -> KEK (the browser end) ------------------------------------------
@@ -137,19 +135,32 @@ export async function unsealValue(sealedB64, vaultPrivB64) {
return obj.v;
}
-// ---- the grant ceremony (handing the private key to a new member) -----------
+// ---- the handoff (handing the vault key to a member by membership) -----------
-// A requester publishes an ephemeral P-256 public key (vault.request); an
-// unlocked member wraps the vault private key to it (vault.grant); the
-// requester opens the grant with the ephemeral private half β€” which only ever
-// existed sealed under their own KEK (ephWrapped) β€” then enrols a durable wrap
-// of their own. wrapThreadKey's format, because the payload IS key bytes.
-export async function grantVaultKey(vaultPrivB64, requesterEphPubB64) {
- return wrapThreadKey(vaultPrivB64, requesterEphPubB64);
+// Access follows THREAD MEMBERSHIP now, not a request/grant dance
+// (docs/locked-vaults.md). When a vault is locked, the locker seals the vault
+// PRIVATE key to each other member's ROSTER public key β€” a `member` handoff β€”
+// so every member is entitled by being in the thread. A member opens their
+// handoff with their own session private key (no biometric β€” it's sealed to
+// their identity key), then re-wraps the vault key under their OWN passkey /
+// keychain (self-enrol) and the handoff is dropped. An already-unlocked insider
+// can also hand a newcomer a fresh handoff (vault.give).
+//
+// It's the same ECIES as a value seal (sealForPub/openSealed); the envelope
+// just carries key bytes under `k` (vs a value's `v`), so the two never confuse.
+export async function sealVaultKeyTo(memberPubB64, vaultPrivB64) {
+ return sealForPub(memberPubB64, { k: vaultPrivB64 });
}
-export async function acceptGrant(grantedB64, ephPrivB64) {
- return unwrapThreadKey(grantedB64, ephPrivB64);
+// openVaultHandoff recovers the vault private key from a handoff sealed to me,
+// using my session (roster) private key. Throws on a blob not sealed to me, or
+// one that isn't a handoff. The web opens handoffs through the host bridge
+// (shell.js holds the session key); this is the pure helper the CLI and tests
+// use directly.
+export async function openVaultHandoff(sealedB64, memberPrivB64) {
+ const obj = await openSealed(sealedB64, memberPrivB64);
+ if (typeof obj.k !== "string") throw new Error("bad handoff");
+ return obj.k;
}
// ---- WebAuthn ceremonies (browser only) --------------------------------------