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>
14 files changed,
+825
−615
changelog/2026-07-15-locked-vaults.md+5 −4docs/locked-vaults.md+91 −37docs/operator-vault-migration.md+56 −29eleven/vault.go+131 −95internal/client/client.go+44 −0internal/vault/vault.go+56 −85internal/vault/vault_test.go+2 −9test/apps.test.mjs+38 −23test/event-vectors.test.mjs+2 −7test/event-vectors/vault.json+74 −73test/vaultcrypto.test.mjs+16 −20web/static/apps/vault.js+247 −218web/static/shell.js+37 −0web/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) -------------------------------------- |