eleven / messenger Public

vault: remove the passphrase recovery wrap β€” recovery is a passkey, never a passphrase (#1354)

Paul's call (2026-07-25): no passphrase-shaped recovery anywhere. The
kind:"recovery" break-glass (#995–#997, built for the retired operator-vault
migration and never minted in anger) is gone end to end:

- eleven CLI: recovery-wrap verb, get/enroll -recovery, the passphrase
  prompts and eleven/recovery.go deleted
- reducer twins (apps/vault.js + internal/vault): wrapShape rejects the
  kind; the revoke guard is back to last-wrap-only; Access panel no longer
  renders recovery rows
- vaultcrypto.js: PBKDF2 wrap/unwrap helpers deleted; cross-impl
  recovery-vector.json and its tests on both sides deleted
- the event vector now pins the OPPOSITE contract: a legacy/hostile
  kind:recovery enroll folds to a no-op
- docs: locked-vaults.md revoke rule; operator-vault-migration.md banner
  notes R1/R2 describe removed machinery
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell abdb245f0dd03d2b6860c8007f2ed078568fbcf4 parent 73ed214
13 files changed, +69 −444
  • changelog/2026-07-25-vault-recovery-is-a-passkey.md +4 −0
  • docs/locked-vaults.md +3 −3
  • docs/operator-vault-migration.md +4 −0
  • eleven/recovery.go +0 −64
  • eleven/recovery_test.go +0 −80
  • eleven/vault.go +27 −156
  • internal/vault/vault.go +7 −10
  • test/apps.test.mjs +4 −8
  • test/event-vectors/vault.json +6 −11
  • test/recovery-vector.json +0 −7
  • test/vaultcrypto.test.mjs +0 −37
  • web/static/apps/vault.js +14 −20
  • web/static/vaultcrypto.js +0 −48
diff --git a/changelog/2026-07-25-vault-recovery-is-a-passkey.md b/changelog/2026-07-25-vault-recovery-is-a-passkey.md
new file mode 100644
index 0000000..8cfd6ca
--- /dev/null
+++ b/changelog/2026-07-25-vault-recovery-is-a-passkey.md
@@ -0,0 +1,4 @@
+πŸ€– Locked vaults no longer offer a passphrase "recovery key". Recovery is a
+passkey: add one from another device or a synced password manager, and any
+member who still has access can always give yours back. One kind of key,
+nothing to write down, no back door to lose.
diff --git a/docs/locked-vaults.md b/docs/locked-vaults.md
index 73deba1..b3efd9b 100644
--- a/docs/locked-vaults.md
+++ b/docs/locked-vaults.md
@@ -126,9 +126,9 @@ inputs, which `groupCapable` requires):
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.revoke { aid, wrapId }` β€” drop a wrap. Never the last wrap (it would
+ strand the vault keyless). 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
diff --git a/docs/operator-vault-migration.md b/docs/operator-vault-migration.md
index a5b49cf..082b4cd 100644
--- a/docs/operator-vault-migration.md
+++ b/docs/operator-vault-migration.md
@@ -3,6 +3,10 @@
> πŸ€– **Historical record β€” not a procedure.** The sops vault is retired and
> the standing store is Bitwarden; what scripts read from it is pinned by
> the vault contract (`deploy/vault-items.sh`). Operate via `./deploy/ops`.
+> The passphrase-derived recovery wrap this plan drove (R1/R2, #995–#997) was
+> **removed from the product 2026-07-25** (Paul: recovery is a passkey, never
+> a passphrase) β€” the reducers now reject `kind: "recovery"`, and the R1/R2
+> text below describes machinery that no longer exists.
The plan of record for moving the fleet's operator secrets off the sops+age
S3-bundle vault (`docs/secrets-vault.md`, RUNBOOK.md "Secrets vault") and onto a
diff --git a/eleven/recovery.go b/eleven/recovery.go
deleted file mode 100644
index a19b4c2..0000000
--- a/eleven/recovery.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package main
-
-// πŸ€– The recovery wrap's crypto (docs/operator-vault-migration.md R1, #995) β€”
-// the passphrase-derived, any-machine break-glass. Pure Go, no cgo: this is the
-// path that MUST work on a fresh Mac, a Linux box, or a CGO_ENABLED=0 build,
-// with no keychain and no authenticator. It reproduces vaultcrypto.js's
-// deriveRecoveryKEK / wrapVaultPrivRecovery byte-for-byte (PBKDF2-SHA256,
-// self-describing salt(16)||iv(12)||ct), pinned by test/recovery-vector.json so
-// a wrap minted in the browser opens here and vice-versa.
-
-import (
- "crypto/rand"
- "crypto/sha256"
- "fmt"
-
- "golang.org/x/crypto/pbkdf2"
-
- "lchat/internal/e2ee"
-)
-
-// vaultRecoveryIters must equal vaultcrypto.js VAULT_RECOVERY_ITERS.
-const vaultRecoveryIters = 600000
-const recoverySaltLen = 16
-
-// deriveRecoveryKEK stretches a passphrase (+ salt) into the 32-byte AES-GCM KEK.
-func deriveRecoveryKEK(passphrase string, salt []byte) []byte {
- return pbkdf2.Key([]byte(passphrase), salt, vaultRecoveryIters, 32, sha256.New)
-}
-
-// wrapVaultPrivRecovery mints a fresh salt and wraps the vault private key
-// (pkcs8, base64url) β†’ base64url( salt(16) || iv(12) || ct ).
-func wrapVaultPrivRecovery(privB64, passphrase string) (string, error) {
- salt := make([]byte, recoverySaltLen)
- if _, err := rand.Read(salt); err != nil {
- return "", err
- }
- priv, err := e2ee.B64uDecode(privB64)
- if err != nil {
- return "", err
- }
- body, err := e2ee.EncryptBytes(deriveRecoveryKEK(passphrase, salt), priv) // iv||ct
- if err != nil {
- return "", err
- }
- return e2ee.B64uEncode(append(salt, body...)), nil
-}
-
-// unwrapVaultPrivRecovery peels the salt, re-derives the KEK, and unwraps. A
-// wrong passphrase fails AES-GCM authentication β€” never a wrong-but-accepted key.
-func unwrapVaultPrivRecovery(wrappedB64, passphrase string) (string, error) {
- buf, err := e2ee.B64uDecode(wrappedB64)
- if err != nil {
- return "", err
- }
- if len(buf) < recoverySaltLen+12 {
- return "", fmt.Errorf("recovery wrap too short")
- }
- salt, body := buf[:recoverySaltLen], buf[recoverySaltLen:]
- priv, err := e2ee.DecryptBytes(deriveRecoveryKEK(passphrase, salt), body)
- if err != nil {
- return "", err
- }
- return e2ee.B64uEncode(priv), nil
-}
diff --git a/eleven/recovery_test.go b/eleven/recovery_test.go
deleted file mode 100644
index feffea9..0000000
--- a/eleven/recovery_test.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package main
-
-// πŸ€– Cross-impl pin for the vault recovery wrap (#995). The recovery KEK format
-// (PBKDF2-SHA256, salt(16)||iv(12)||ct) must be byte-identical to
-// vaultcrypto.js, so a wrap minted in the browser opens in the CLI and vice
-// versa β€” the escrowed break-glass has to work no matter where it was made.
-// test/recovery-vector.json carries a JS-minted blob AND a Go-minted blob; both
-// suites (this + test/vaultcrypto.test.mjs) unwrap BOTH to the same key.
-
-import (
- "encoding/json"
- "os"
- "testing"
-)
-
-func loadRecoveryVector(t *testing.T) struct {
- Passphrase string `json:"passphrase"`
- PrivateKey string `json:"privateKey"`
- JsWrapped string `json:"jsWrapped"`
- GoWrapped string `json:"goWrapped"`
-} {
- t.Helper()
- raw, err := os.ReadFile("../test/recovery-vector.json")
- if err != nil {
- t.Fatalf("read vector: %v", err)
- }
- var v struct {
- Passphrase string `json:"passphrase"`
- PrivateKey string `json:"privateKey"`
- JsWrapped string `json:"jsWrapped"`
- GoWrapped string `json:"goWrapped"`
- }
- if err := json.Unmarshal(raw, &v); err != nil {
- t.Fatalf("parse vector: %v", err)
- }
- return v
-}
-
-// TestRecoveryVector proves Go opens the JS-minted blob (and, once filled, the
-// Go-minted blob) — the JS→Go and Go→Go directions.
-func TestRecoveryVector(t *testing.T) {
- v := loadRecoveryVector(t)
- got, err := unwrapVaultPrivRecovery(v.JsWrapped, v.Passphrase)
- if err != nil {
- t.Fatalf("Go could not open the JS-minted recovery wrap: %v", err)
- }
- if got != v.PrivateKey {
- t.Fatalf("JS→Go mismatch:\n got %s\nwant %s", got, v.PrivateKey)
- }
- if v.GoWrapped != "" && v.GoWrapped != "PLACEHOLDER_FILLED_BY_GO_TEST" {
- got, err := unwrapVaultPrivRecovery(v.GoWrapped, v.Passphrase)
- if err != nil || got != v.PrivateKey {
- t.Fatalf("Go could not open the committed Go-minted wrap: %v (got %s)", err, got)
- }
- }
- // Emit a fresh Go-minted blob for the vector's goWrapped field (fill it in
- // once; the JS suite must then open it too). `go test -run RecoveryVector -v`.
- mint, err := wrapVaultPrivRecovery(v.PrivateKey, v.Passphrase)
- if err != nil {
- t.Fatalf("Go mint: %v", err)
- }
- t.Logf("goWrapped=%s", mint)
-}
-
-// TestRecoveryRoundTrip: Go wrap β†’ Go unwrap, and a wrong passphrase never
-// yields a wrong-but-accepted key (it fails the GCM tag).
-func TestRecoveryRoundTrip(t *testing.T) {
- v := loadRecoveryVector(t)
- wrapped, err := wrapVaultPrivRecovery(v.PrivateKey, "another-strong-passphrase-xyz")
- if err != nil {
- t.Fatal(err)
- }
- back, err := unwrapVaultPrivRecovery(wrapped, "another-strong-passphrase-xyz")
- if err != nil || back != v.PrivateKey {
- t.Fatalf("round-trip failed: %v (got %s)", err, back)
- }
- if _, err := unwrapVaultPrivRecovery(wrapped, "wrong-passphrase"); err == nil {
- t.Fatal("a wrong passphrase must never open the recovery wrap")
- }
-}
diff --git a/eleven/vault.go b/eleven/vault.go
index 98b4c93..97db386 100644
--- a/eleven/vault.go
+++ b/eleven/vault.go
@@ -40,7 +40,6 @@ func runVault(spaces []*space, args []string) {
fromFile := fs.String("from-file", "", "`set`: read the value from a file (e.g. an .env) instead of prompting")
label := fs.String("label", "", "`enroll`: how this key is listed in the vault's Access panel")
dbPath := fs.String("db", "", "read a raw instance DB file OFFLINE (a copy of /data/<host>.db or an S3 replica restore) β€” no server involved; list/get only")
- recovery := fs.Bool("recovery", false, "`get`: open via the recovery passphrase (any machine, no keychain) instead of Touch ID (#995/#996)")
outFile := fs.String("o", "", "`get`: write the value to this file (mode 600) instead of stdout β€” the deploy/DR-safe form (#997)")
fs.Usage = vaultUsage
_ = fs.Parse(rest)
@@ -50,7 +49,7 @@ func runVault(spaces []*space, args []string) {
if *dbPath != "" && verb != "list" && verb != "get" {
die("only list and get work offline (-db) β€” %s needs the live space", verb)
}
- opts := getOpts{recovery: *recovery, outFile: *outFile}
+ opts := getOpts{outFile: *outFile}
switch verb {
case "list":
@@ -63,7 +62,7 @@ func runVault(spaces []*space, args []string) {
vaultList(title, foldVault(sp, tid))
case "get":
if len(rest) != 1 {
- die("usage: eleven vault get [-chat c] [-db f] [-recovery] [-o file] <name>")
+ die("usage: eleven vault get [-chat c] [-db f] [-o file] <name>")
}
if *dbPath != "" {
host, tid, _, s := vaultOffline(spaces, *dbPath, *chat)
@@ -80,14 +79,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, *recovery)
+ vaultEnroll(sp, tid, foldVault(sp, tid), *label)
case "give":
who := strings.Join(rest, " ")
sp, tid, _ := vaultTarget(spaces, *chat)
vaultGive(sp, tid, foldVault(sp, tid), who)
- case "recovery-wrap":
- sp, tid, _ := vaultTarget(spaces, *chat)
- vaultRecoveryWrap(sp, tid, foldVault(sp, tid))
default:
fmt.Fprintf(os.Stderr, "eleven: unknown vault command %q\n\n", verb)
vaultUsage()
@@ -101,26 +97,18 @@ func vaultUsage() {
commands:
list names, who added them, and the vault's lock state
β€” never the values
- get [-o f] [-recovery] <name>
- print one value (a locked vault asks for Touch ID);
- -o writes it to a mode-600 file instead of stdout;
- -recovery opens via the passphrase (any machine)
+ get [-o f] <name> print one value (a locked vault asks for Touch ID);
+ -o writes it to a mode-600 file instead of stdout
set [-from-file f] <name>
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] [-recovery]
- set up THIS machine's key from your handoff β€” the
+ enroll [-label s] 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
flags:
-chat <needle> target a group chat's shared Vault instead of your
@@ -128,17 +116,15 @@ flags:
-db <file> OFFLINE list/get from a raw instance DB β€” a copy of
/data/<host>.db or an S3 replica restore. No server
anywhere.
- -recovery get: open sealed values with the recovery passphrase
- 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
logged or Claude-driven shell).
Your personal Vault is the default target. Bare "get" prints to stdout for
VAR="$(eleven vault get name)"; NEVER use that form in a logged/Claude shell β€”
-use -o. Recovery + offline: eleven vault get -db <replica> -recovery -o f <name>.
+use -o. There is no passphrase back door: access is your enrolled keys β€”
+lose them all and it's the other members (or a synced passkey) that let you
+back in.
`)
}
@@ -306,14 +292,11 @@ func vaultList(title string, s vault.State) {
}
}
-// getOpts carries the `get` flags: recovery (open sealed items via the
-// passphrase-derived recovery wrap instead of the keychain β€” any machine, #996)
-// and outFile (write the value to a mode-600 file instead of stdout β€” the
-// deploy/DR-safe form that keeps secrets out of shell history / a transcript,
-// #997).
+// getOpts carries the `get` flags: outFile (write the value to a mode-600 file
+// instead of stdout β€” the deploy/DR-safe form that keeps secrets out of shell
+// history / a transcript, #997).
type getOpts struct {
- recovery bool
- outFile string
+ outFile string
}
func vaultGet(host, threadID string, s vault.State, name string, opts getOpts) {
@@ -323,12 +306,7 @@ func vaultGet(host, threadID string, s vault.State, name string, opts getOpts) {
}
value := it.Value
if it.Sealed != "" {
- priv := ""
- if opts.recovery {
- priv = recoverVaultPriv(s) // passphrase, no keychain β€” works on any machine
- } else {
- priv = unlockVaultPriv(host, threadID, s) // Touch ID
- }
+ priv := unlockVaultPriv(host, threadID, s) // Touch ID
var env struct {
V string `json:"v"`
}
@@ -372,57 +350,6 @@ func writeSecretFile(path, value string) error {
return err
}
-// recoverVaultPriv opens the vault private key via the passphrase-derived
-// recovery wrap (#995) β€” the any-machine break-glass, no keychain, no biometric.
-func recoverVaultPriv(s vault.State) string {
- if s.Keyring == nil {
- die("this vault isn't locked β€” nothing to recover")
- }
- var rec *vault.Wrap
- for i := range s.Keyring.Wraps {
- if s.Keyring.Wraps[i].Kind == "recovery" {
- rec = &s.Keyring.Wraps[i]
- }
- }
- if rec == nil {
- die("this vault has no recovery key β€” an operator mints one with `eleven vault recovery-wrap`")
- }
- priv, err := unwrapVaultPrivRecovery(rec.Wrapped, readPassphrase("Recovery passphrase"))
- if err != nil {
- die("that passphrase doesn't open the recovery key")
- }
- return priv
-}
-
-// vaultRecoveryWrap mints the vault's recovery key: unlock the vault key (Touch
-// ID), wrap it under a high-entropy passphrase (PBKDF2), and enrol it as an
-// un-revokable `recovery` wrap. The operator escrows the passphrase in
-// 1Password β€” it's the vault's any-machine, sops-parity break-glass.
-func vaultRecoveryWrap(sp *space, threadID string, s vault.State) {
- if s.Keyring == nil {
- die("this vault isn't locked yet β€” lock it (in the app) or enrol first")
- }
- for _, w := range s.Keyring.Wraps {
- if w.Kind == "recovery" {
- die("a recovery key is already enrolled β€” it's un-revokable, so there's nothing to replace")
- }
- }
- priv := unlockVaultPriv(spaceHost(sp.sess), threadID, s) // Touch ID β†’ the vault key
- pass := readPassphraseConfirmed("New recovery passphrase (use a 1Password-generated one)")
- wrapped, err := wrapVaultPrivRecovery(priv, pass)
- if err != nil {
- die("%v", err)
- }
- err = sp.c.SendEvent(threadID, map[string]any{
- "type": "vault.enroll", "aid": newID(),
- "wrap": map[string]any{"id": newID(), "kind": "recovery", "label": "Recovery key (1Password)", "wrapped": wrapped},
- })
- if err != nil {
- die("%v", err)
- }
- fmt.Fprintln(os.Stderr, "Recovery key enrolled. Store the passphrase in 1Password β€” it opens this vault on ANY machine (no keychain, `-recovery`), and it can't be revoked.")
-}
-
func vaultSet(sp *space, threadID string, s vault.State, name, fromFile string) {
value := readSecretValue(name, fromFile)
if value == "" {
@@ -460,11 +387,8 @@ func vaultSet(sp *space, threadID string, s vault.State, name, fromFile string)
// 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) {
+// the drop by uid). No approval: being in the thread is the entitlement.
+func vaultEnroll(sp *space, threadID string, s vault.State, label string) {
if s.Keyring == nil {
die("this vault isn't locked yet β€” lock it in the app first (the first key is a passkey ceremony)")
}
@@ -482,26 +406,20 @@ func vaultEnroll(sp *space, threadID string, s vault.State, label string, recove
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
+ 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)", 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.)
+ // wrapped under the keychain.
for _, it := range s.Items {
if it.Sealed != "" {
var probe struct {
@@ -702,53 +620,6 @@ func readSecretValue(name, fromFile string) string {
return string(first)
}
-// readPassphrase reads one hidden passphrase (for opening a recovery wrap β€” a
-// wrong one just fails the AES-GCM auth, so no confirm needed). Requires a TTY:
-// the passphrase must never come from argv, a pipe, or anything a transcript
-// could capture.
-func readPassphrase(label string) string {
- if !term.IsTerminal(int(os.Stdin.Fd())) {
- die("%s must be typed at a terminal (never piped or in argv)", label)
- }
- fmt.Fprintf(os.Stderr, "%s (hidden): ", label)
- pass, err := term.ReadPassword(int(os.Stdin.Fd()))
- fmt.Fprintln(os.Stderr)
- if err != nil {
- die("%v", err)
- }
- if len(pass) == 0 {
- die("empty passphrase")
- }
- return string(pass)
-}
-
-// readPassphraseConfirmed reads a hidden passphrase twice (for MINTING a
-// recovery wrap β€” a typo would escrow an unopenable key).
-func readPassphraseConfirmed(label string) string {
- if !term.IsTerminal(int(os.Stdin.Fd())) {
- die("%s must be typed at a terminal (never piped or in argv)", label)
- }
- fmt.Fprintf(os.Stderr, "%s (hidden): ", label)
- first, err := term.ReadPassword(int(os.Stdin.Fd()))
- fmt.Fprintln(os.Stderr)
- if err != nil {
- die("%v", err)
- }
- if len(first) < 12 {
- die("use a longer, high-entropy passphrase (a 1Password-generated one) β€” this is the vault's break-glass")
- }
- fmt.Fprint(os.Stderr, "Once more to confirm: ")
- second, err := term.ReadPassword(int(os.Stdin.Fd()))
- fmt.Fprintln(os.Stderr)
- if err != nil {
- die("%v", err)
- }
- if string(first) != string(second) {
- die("the two entries differ β€” nothing enrolled")
- }
- return string(first)
-}
-
func orSomeone(name string) string {
if name == "" {
return "someone"
diff --git a/internal/vault/vault.go b/internal/vault/vault.go
index 5223c0e..b14b577 100644
--- a/internal/vault/vault.go
+++ b/internal/vault/vault.go
@@ -42,15 +42,16 @@ type LogEntry struct {
// 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.
+// (A passphrase-derived "recovery" kind existed briefly, #995; removed
+// 2026-07-25 β€” recovery is a passkey, never a passphrase.)
type Wrap struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
- UID string `json:"uid"` // the member this wrap belongs to ("" for a recovery wrap)
+ UID string `json:"uid"` // the member this wrap belongs to
CredID string `json:"credId"`
Wrapped string `json:"wrapped"`
}
@@ -222,17 +223,14 @@ func Fold(s State, e Event) State {
return s
}
gone := ""
- goneKind := ""
found := false
for _, w := range s.Keyring.Wraps {
if w.ID == wrapID {
- gone, goneKind, found = w.Label, w.Kind, true
+ gone, found = w.Label, true
}
}
- // The LAST wrap is unrevokable (it would strand the vault keyless), and a
- // "recovery" wrap (#995) can NEVER be revoked β€” it's the escrowed
- // break-glass, so a hostile session can't strip it and lock out DR.
- if !found || goneKind == "recovery" || len(s.Keyring.Wraps) <= 1 {
+ // The LAST wrap is unrevokable β€” it would strand the vault keyless.
+ if !found || len(s.Keyring.Wraps) <= 1 {
return s
}
kr := *s.Keyring
@@ -268,9 +266,8 @@ func wrapShape(v any) *Wrap {
return nil
}
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") {
+ if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain" && w.Kind != "member") {
return nil
}
w.CredID = str(m, "credId")
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index e5fb73a..3105528 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -2789,16 +2789,12 @@ await test("vault.revoke drops a wrap but never the last one", () => {
assert.equal(s.log.at(-1).action, "revoke");
});
-await test("vault: a recovery wrap (#995) is accepted and is NEVER revokable", () => {
+await test("vault: a legacy recovery wrap (#995, removed) never folds in", () => {
let s = vreduce(undefined, LOCK());
- // enroll a passphrase-derived recovery wrap alongside the passkey wrap
+ // The passphrase-derived break-glass was removed 2026-07-25 β€” recovery is a
+ // passkey, never a passphrase. wrapShape rejects the kind like any unknown one.
s = vreduce(s, { type: "vault.enroll", aid: "a2", wrap: { id: "rec", kind: "recovery", label: "Recovery key (1Password)", wrapped: "recov-blob" } });
- assert.deepEqual(s.keyring.wraps.map((w) => w.kind), ["passkey", "recovery"]);
- // even with 2 wraps present, the recovery one can't be revoked (would strip the escrow)
- assert.equal(vreduce(s, { type: "vault.revoke", aid: "a3", wrapId: "rec" }), s, "recovery wrap is un-revokable");
- // …and it stays un-revokable even as the LAST wrap
- let only = vreduce(undefined, LOCK({ wrap: { id: "rec", kind: "recovery", label: "R", wrapped: "b" } }));
- assert.equal(vreduce(only, { type: "vault.revoke", aid: "a4", wrapId: "rec" }), only);
+ assert.deepEqual(s.keyring.wraps.map((w) => w.kind), ["passkey"], "recovery kind never folds in");
// a bogus kind is still rejected by wrapShape
const bad = vreduce(vreduce(undefined, LOCK()), { type: "vault.enroll", aid: "a5", wrap: { id: "x", kind: "usb-fob", wrapped: "b" } });
assert.equal(bad.keyring.wraps.length, 1, "unknown wrap kind never folds in");
diff --git a/test/event-vectors/vault.json b/test/event-vectors/vault.json
index fafcc0f..2053cd8 100644
--- a/test/event-vectors/vault.json
+++ b/test/event-vectors/vault.json
@@ -5,7 +5,7 @@
"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|give|revoke", "name": "", "by": "", "byName": "", "at": 0 }],
- "keyring": { "v": 1, "pub": "", "wraps": [{ "id": "", "kind": "passkey|keychain|recovery|member", "label": "", "uid": "", "credId": "", "wrapped": "" }] }
+ "keyring": { "v": 1, "pub": "", "wraps": [{ "id": "", "kind": "passkey|keychain|member", "label": "", "uid": "", "credId": "", "wrapped": "" }] }
},
"vectors": [
{
@@ -195,26 +195,21 @@
}
},
{
- "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.",
+ "name": "a legacy recovery wrap folds to a no-op",
+ "note": "the passphrase-derived kind:recovery break-glass (#995) was removed 2026-07-25 β€” recovery is a passkey, never a passphrase. wrapShape rejects the kind, so a historical (or hostile) recovery enroll leaves the keyring untouched.",
"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" }, "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 }
+ { "type": "vault.enroll", "aid": "a2", "wrap": { "id": "rec", "kind": "recovery", "label": "Recovery key (1Password)", "wrapped": "recov-blob" }, "uid": "u1", "name": "Paul", "ts": 2000 }
],
"expected_state": {
"items": [],
"log": [
- { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 },
- { "id": "a2", "ref": "", "action": "enroll", "name": "Recovery key (1Password)", "by": "u1", "byName": "Paul", "at": 2000 }
+ { "id": "a1", "ref": "", "action": "lock", "name": "", "by": "u1", "byName": "Paul", "at": 1000 }
],
"keyring": {
"v": 1,
"pub": "vault-pub",
- "wraps": [
- { "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" }
- ]
+ "wraps": [{ "id": "w1", "kind": "passkey", "uid": "u1", "label": "Paul", "credId": "cred-1", "wrapped": "wrap-1" }]
}
}
},
diff --git a/test/recovery-vector.json b/test/recovery-vector.json
deleted file mode 100644
index e71e797..0000000
--- a/test/recovery-vector.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "_comment": "πŸ€– Cross-impl pin for the vault recovery wrap (#995). vaultcrypto.js and eleven/recovery.go must both unwrap BOTH blobs to privateKey with passphrase. jsWrapped was minted by vaultcrypto.wrapVaultPrivRecovery; goWrapped by eleven/recovery.go wrapVaultPrivRecovery. Format: b64url(salt(16)||iv(12)||ct), KEK=PBKDF2-SHA256(passphrase,salt,600000). Regenerate: node test-gen (see recovery.test) / go test -run Recovery -v.",
- "passphrase": "corr3ct-h0rse-b4ttery-1password-staple",
- "privateKey": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgn7BFDyNQjc8SqBZ21wMSUg4U8y4cW6nOUGRaXR6SzwehRANCAAQZna3LTjGyBUBQi_CAQU35Cs6pKsmP1JE95aBOWfKfdAA2gE8ZWTqf_8f45jcmaHZoG2Xpw3KlCZCh8wP7E7Br",
- "jsWrapped": "k5DyksDVinUwXGKK_EXDbgAgWHgQuBYO3xn6dKdj1gE8Z5tRV3h9zuI80N5p2A2Xnwh37Cb8mbyQxdglBCZayVSC0kHD99hSiM2k_gcB6GOU7bKM_CjWcfkrWANlxhmnonhWTN1het8_8U68nMd6fGBvjXsj7t2jRxFFKQBoUCigrT5Nu60w0Gxx4KNYKQftecE6RvVi2wel06Cgml_gxS6-NYsECmNgQW02Y3q23vhQYislSiQ",
- "goWrapped": "zct_bkbDlMXKtEbbRkCsBJATB-Mu4zHORnq3lfUY7IOkzIVFCgSgOflp0Xw45u7OGhZzmY6RKC5_NEoOu5KbLa9_ZulIhK9laBa8t5AMMEQw3pWt_FczXyCyDXxbjak-eAR2acJbDgbdqB9F02yrvNlvRnIclIo15lhtJDTSd9G0qCp5i9jnXGqAPSlsLTq_3DvQqMVtBABqVJak9FFpcI-F-GwXKHKmVExPtx3iM90d8g7LNSk"
-}
diff --git a/test/vaultcrypto.test.mjs b/test/vaultcrypto.test.mjs
index a40bdb7..4dcc165 100644
--- a/test/vaultcrypto.test.mjs
+++ b/test/vaultcrypto.test.mjs
@@ -6,9 +6,6 @@
// bytes, so the pipeline from PRF output to unsealed value is exercised end to
// end, with only the authenticator itself stubbed out.
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
-import { fileURLToPath } from "node:url";
-import { dirname, join } from "node:path";
import {
prfEvalInput,
kekFromPRF,
@@ -20,8 +17,6 @@ import {
unsealValue,
sealVaultKeyTo,
openVaultHandoff,
- wrapVaultPrivRecovery,
- unwrapVaultPrivRecovery,
} from "../web/static/vaultcrypto.js";
let failures = 0;
@@ -107,37 +102,5 @@ await test("the membership handoff: lock hands the key to a member's roster key
await assert.rejects(() => openVaultHandoff(handoff, stranger.privateKey));
});
-// ---- πŸ€– the recovery wrap (#995) β€” passphrase break-glass ---------------------
-
-await test("recovery wrap: passphrase round-trip (incl. unicode passphrase)", async () => {
- const kp = await generateVaultKeypair();
- for (const pass of ["a-strong-1password-passphrase", "pΓ€55phrΓ€se with spaces πŸ”‘", "x".repeat(200)]) {
- const wrapped = await wrapVaultPrivRecovery(kp.privateKey, pass);
- assert.equal(await unwrapVaultPrivRecovery(wrapped, pass), kp.privateKey);
- }
-});
-
-await test("recovery wrap: a wrong passphrase throws (never a wrong-but-accepted key)", async () => {
- const kp = await generateVaultKeypair();
- const wrapped = await wrapVaultPrivRecovery(kp.privateKey, "the-right-passphrase-here");
- await assert.rejects(async () => unwrapVaultPrivRecovery(wrapped, "the-wrong-passphrase"));
-});
-
-await test("recovery wrap: fresh salt each time (two wraps of the same key differ)", async () => {
- const kp = await generateVaultKeypair();
- const a = await wrapVaultPrivRecovery(kp.privateKey, "same-passphrase-both");
- const b = await wrapVaultPrivRecovery(kp.privateKey, "same-passphrase-both");
- assert.notEqual(a, b); // random salt+iv β€” no deterministic ciphertext
-});
-
-await test("recovery wrap: cross-impl vector β€” JS opens BOTH the JS- and Go-minted blobs", async () => {
- // The same file eleven/recovery_test.go reads; opening the Go-minted blob is
- // the Go→JS direction of the pin (JS→Go is asserted on the Go side).
- const v = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "recovery-vector.json"), "utf8"));
- assert.equal(await unwrapVaultPrivRecovery(v.jsWrapped, v.passphrase), v.privateKey, "JS opens its own blob");
- assert.notEqual(v.goWrapped, "PLACEHOLDER_FILLED_BY_GO_TEST", "goWrapped must be filled from the Go test");
- assert.equal(await unwrapVaultPrivRecovery(v.goWrapped, v.passphrase), v.privateKey, "JS opens the Go-minted blob (Go→JS)");
-});
-
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/apps/vault.js b/web/static/apps/vault.js
index ae0aae2..54f8cc4 100644
--- a/web/static/apps/vault.js
+++ b/web/static/apps/vault.js
@@ -111,8 +111,6 @@ function findByName(items, name) {
// 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
@@ -121,7 +119,7 @@ function findByName(items, name) {
// 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;
- if (w.kind !== "passkey" && w.kind !== "keychain" && w.kind !== "recovery" && w.kind !== "member") return null;
+ if (w.kind !== "passkey" && w.kind !== "keychain" && w.kind !== "member") return null;
if (typeof w.wrapped !== "string" || !w.wrapped) return null;
const out = {
id: w.id,
@@ -279,12 +277,11 @@ export const app = defineApp({
// 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, and a recovery wrap (#995) is un-revokable β€” the escrowed
- // break-glass a hostile session must not be able to strip.
+ // dropped β€” it would strand the vault keyless.
"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);
- if (!gone || gone.kind === "recovery" || s.keyring.wraps.length <= 1) return s;
+ if (!gone || s.keyring.wraps.length <= 1) return s;
return {
...s,
log: logEntry(s.log, e, "revoke", gone.label),
@@ -1105,22 +1102,19 @@ function buildAccess(keyring, ctx, say) {
const li = document.createElement("li");
const who = document.createElement("span");
who.className = "vault-access-who";
- who.textContent = w.kind === "recovery" ? w.label || "Recovery key" : w.label || "someone";
+ who.textContent = w.label || "someone";
// 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";
+ w.kind === "keychain"
+ ? " Β· CLI key"
+ : w.kind === "member"
+ ? " Β· invited β€” waiting to set up a passkey"
+ : " Β· passkey";
li.append(who, document.createTextNode(kindLabel));
- // 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") {
+ // Revoke drops a wrap (housekeeping, not crypto revocation). Never the last
+ // β€” so we never offer a Revoke that would silently no-op.
+ if (wraps.length > 1) {
const revoke = document.createElement("button");
revoke.type = "button";
revoke.className = "vault-btn vault-btn--danger";
@@ -1171,8 +1165,8 @@ function buildAccess(keyring, ctx, say) {
}
// 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.
+ // who can actually unlock (doGive needs the vault key). CLI-only holders can
+ // still give from their own end.
if (iAmEnrolled) {
for (const m of missing) {
const give = document.createElement("button");
diff --git a/web/static/vaultcrypto.js b/web/static/vaultcrypto.js
index cf0c2e5..7620206 100644
--- a/web/static/vaultcrypto.js
+++ b/web/static/vaultcrypto.js
@@ -70,54 +70,6 @@ export async function unwrapVaultPriv(wrappedB64, kek) {
return bytesToB64url(await decryptBytes(kek, b64urlToBytes(wrappedB64)));
}
-// ---- recovery wrap (docs/operator-vault-migration.md R1, #995) ---------------
-//
-// A `kind: "recovery"` keyring wrap holds the vault private key under a KEK
-// derived from a high-entropy PASSPHRASE (PBKDF2-SHA256), not a biometric β€” so
-// it opens on ANY machine (a fresh Mac, a Linux box, `CGO_ENABLED=0`), with no
-// keychain and no authenticator. The passphrase is escrowed in 1Password; this
-// is the vault's sops-parity break-glass, and the reducer keeps a recovery wrap
-// un-revokable so a hostile session can't strip the escrow.
-//
-// The wrap is self-describing: b64url( salt(16) || iv(12) || ciphertext ). The
-// salt is public (it only individualises the KDF); the iters are the fixed
-// constant below. The Go side (eleven/recovery.go) reproduces this byte-for-byte
-// so a wrap minted on either end opens on the other β€” pinned by a cross-impl
-// vector (test/recovery-vector.json).
-export const VAULT_RECOVERY_ITERS = 600_000;
-const RECOVERY_SALT_LEN = 16;
-
-// deriveRecoveryKEK stretches a passphrase (+ salt bytes) into the AES-GCM KEK.
-export async function deriveRecoveryKEK(passphrase, saltBytes) {
- const ikm = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]);
- return crypto.subtle.deriveKey(
- { name: "PBKDF2", hash: "SHA-256", salt: saltBytes, iterations: VAULT_RECOVERY_ITERS },
- ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
- );
-}
-
-// wrapVaultPrivRecovery mints a fresh salt, derives the KEK, and wraps the vault
-// private key. Returns b64url( salt || iv || ct ).
-export async function wrapVaultPrivRecovery(privB64, passphrase) {
- const salt = crypto.getRandomValues(new Uint8Array(RECOVERY_SALT_LEN));
- const kek = await deriveRecoveryKEK(passphrase, salt);
- const body = await encryptBytes(kek, b64urlToBytes(privB64)); // iv || ct
- const out = new Uint8Array(salt.length + body.length);
- out.set(salt, 0);
- out.set(body, salt.length);
- return bytesToB64url(out);
-}
-
-// unwrapVaultPrivRecovery peels the salt, re-derives the KEK, and unwraps. Throws
-// on a wrong passphrase (AES-GCM auth failure) β€” never a wrong-but-accepted key.
-export async function unwrapVaultPrivRecovery(wrappedB64, passphrase) {
- const buf = b64urlToBytes(wrappedB64);
- const salt = buf.slice(0, RECOVERY_SALT_LEN);
- const body = buf.slice(RECOVERY_SALT_LEN); // iv || ct
- const kek = await deriveRecoveryKEK(passphrase, salt);
- return bytesToB64url(await decryptBytes(kek, body));
-}
-
// ---- sealing values ----------------------------------------------------------
// sealValue ECIES-seals one secret value to the vault public key. The value