eleven / messenger Public

vault: recovery escrow β€” un-revokable passphrase wrap + get --recovery + get -o (#995/#996/#997) (#1000)

The DR-parity prerequisites the migration-plan red-team required before the
operator vault can retire sops. Without them the vault is unrecoverable on a
fresh machine (keychain KEK is Mac-local); with them it recovers on any
machine from a 1Password-escrowed passphrase, matching sops.

R1 (#995): a kind:"recovery" keyring wrap β€” the vault private key under a
PBKDF2-SHA256 (600k) KEK from a high-entropy passphrase, self-describing
salt(16)||iv(12)||ct. vaultcrypto.js and eleven/recovery.go implement it
byte-identically, pinned bidirectionally by test/recovery-vector.json (each
side opens the other's blob). The reducer (vault.js + internal/vault twin,
+ vault.json vector) accepts kind:recovery and makes it UN-REVOKABLE, so a
compromised session can't strip the escrow and lock out DR. Access panel
shows it, never offers Revoke.

R2 (#996): eleven vault get -db --recovery β€” opens sealed values via the
passphrase (pure Go, no keychain), so recovery works on a cold Mac / Linux /
CGO_ENABLED=0. eleven vault recovery-wrap mints the wrap (Touch ID to read the
key, hidden passphrase to wrap it).

R4 (#997): eleven vault get -o <file> writes the value to a mode-600 file
instead of stdout β€” the deploy/DR-safe form that keeps secrets out of a
logged or Claude-driven shell (mirrors sops -d).

Closes #995, closes #996, closes #997.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 80b7eb61c1673aa2b638bec3b17c6556ae0d6581 parent 674ac57
10 files changed, +464 −20
  • eleven/recovery.go +64 −0
  • eleven/recovery_test.go +80 −0
  • eleven/vault.go +167 −10
  • internal/vault/vault.go +8 −5
  • test/apps.test.mjs +15 −0
  • test/event-vectors/vault.json +25 −0
  • test/recovery-vector.json +7 −0
  • test/vaultcrypto.test.mjs +37 −0
  • web/static/apps/vault.js +13 −5
  • web/static/apps/vaultcrypto.js +48 −0
diff --git a/eleven/recovery.go b/eleven/recovery.go
new file mode 100644
index 0000000..a19b4c2
--- /dev/null
+++ b/eleven/recovery.go
@@ -0,0 +1,64 @@
+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
new file mode 100644
index 0000000..feffea9
--- /dev/null
+++ b/eleven/recovery_test.go
@@ -0,0 +1,80 @@
+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 670e783..2a2bfd8 100644
--- a/eleven/vault.go
+++ b/eleven/vault.go
@@ -39,6 +39,8 @@ 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)
rest = fs.Args()
@@ -47,6 +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}
switch verb {
case "list":
@@ -59,15 +62,15 @@ 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] <name>")
+ die("usage: eleven vault get [-chat c] [-db f] [-recovery] [-o file] <name>")
}
if *dbPath != "" {
host, tid, _, s := vaultOffline(spaces, *dbPath, *chat)
- vaultGet(host, tid, s, rest[0])
+ vaultGet(host, tid, s, rest[0], opts)
return
}
sp, tid, _ := vaultTarget(spaces, *chat)
- vaultGet(spaceHost(sp.sess), tid, foldVault(sp, tid), rest[0])
+ vaultGet(spaceHost(sp.sess), tid, foldVault(sp, tid), rest[0], opts)
case "set":
if len(rest) != 1 {
die("usage: eleven vault set [-chat c] [-from-file f] <name>")
@@ -84,6 +87,9 @@ func runVault(spaces []*space, args []string) {
who := strings.Join(rest, " ")
sp, tid, _ := vaultTarget(spaces, *chat)
vaultApprove(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()
@@ -97,7 +103,10 @@ func vaultUsage() {
commands:
list names, who added them, and the vault's lock state
β€” never the values
- get <name> print one value (a locked vault asks for Touch ID)
+ 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)
set [-from-file f] <name>
store/overwrite a value β€” hidden prompt, piped
stdin, or a file; on a locked vault it seals to the
@@ -106,16 +115,26 @@ commands:
(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)
+ 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
personal one (substring match, like eleven chat)
-db <file> OFFLINE list/get from a raw instance DB β€” a copy of
/data/<host>.db or an S3 replica restore. No server
- anywhere; your ~/.eleven key + Touch ID still apply.
+ anywhere.
+ -recovery get: open sealed values with the recovery passphrase
+ instead of the keychain β€” works 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. Values print to stdout; everything
-else goes to stderr, so VAR="$(eleven vault get name)" stays clean.
+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>.
`)
}
@@ -275,14 +294,29 @@ func vaultList(title string, s vault.State) {
}
}
-func vaultGet(host, threadID string, s vault.State, name string) {
+// 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).
+type getOpts struct {
+ recovery bool
+ outFile string
+}
+
+func vaultGet(host, threadID string, s vault.State, name string, opts getOpts) {
it := s.FindItem(name)
if it == nil {
die("no secret named %q β€” `eleven vault list` shows what's there", name)
}
value := it.Value
if it.Sealed != "" {
- priv := unlockVaultPriv(host, threadID, s)
+ priv := ""
+ if opts.recovery {
+ priv = recoverVaultPriv(s) // passphrase, no keychain β€” works on any machine
+ } else {
+ priv = unlockVaultPriv(host, threadID, s) // Touch ID
+ }
var env struct {
V string `json:"v"`
}
@@ -291,8 +325,18 @@ func vaultGet(host, threadID string, s vault.State, name string) {
}
value = env.V
}
+ if opts.outFile != "" {
+ // Root-only (0600), never stdout β€” safe under a Claude-driven or logged
+ // shell, mirroring `sops -d`'s write-to-file. Overwrite deliberately.
+ if err := writeSecretFile(opts.outFile, value); err != nil {
+ die("%v", err)
+ }
+ fmt.Fprintf(os.Stderr, "Wrote %q to %s (mode 600).\n", name, opts.outFile)
+ return
+ }
// The raw value on stdout (everything else rides stderr), newline-terminated
- // for the shell; $(…) substitution strips it.
+ // for the shell; $(…) substitution strips it. NEVER run this form in a
+ // Claude-driven / logged shell β€” use -o (#997).
if _, err := io.WriteString(os.Stdout, value); err != nil {
die("%v", err)
}
@@ -301,6 +345,72 @@ func vaultGet(host, threadID string, s vault.State, name string) {
}
}
+// writeSecretFile writes a secret to path with mode 0600, forcing the perms even
+// if the file pre-existed with looser ones.
+func writeSecretFile(path, value string) error {
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if err := f.Chmod(0o600); err != nil {
+ return err
+ }
+ _, err = io.WriteString(f, value)
+ 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 == "" {
@@ -556,6 +666,53 @@ 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 0661c4e..43ad772 100644
--- a/internal/vault/vault.go
+++ b/internal/vault/vault.go
@@ -197,15 +197,17 @@ func Fold(s State, e Event) State {
return s
}
gone := ""
+ goneKind := ""
found := false
for _, w := range s.Keyring.Wraps {
if w.ID == wrapID {
- gone, found = w.Label, true
+ gone, goneKind, found = w.Label, w.Kind, true
}
}
- // The LAST wrap is unrevokable β€” dropping it would strand the vault
- // with no openable key, permanently.
- if !found || len(s.Keyring.Wraps) <= 1 {
+ // 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 {
return s
}
kr := *s.Keyring
@@ -268,7 +270,8 @@ func wrapShape(v any) *Wrap {
return nil
}
w := Wrap{ID: str(m, "id"), Kind: str(m, "kind"), Label: str(m, "label"), Wrapped: str(m, "wrapped")}
- if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain") {
+ // "recovery" (#995) = a passphrase-derived, un-revokable break-glass wrap.
+ if w.ID == "" || w.Wrapped == "" || (w.Kind != "passkey" && w.Kind != "keychain" && w.Kind != "recovery") {
return nil
}
w.CredID = str(m, "credId")
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 3fe1e7e..1f17c79 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -2075,6 +2075,21 @@ 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", () => {
+ let s = vreduce(undefined, LOCK());
+ // enroll a passphrase-derived recovery wrap alongside the passkey wrap
+ 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);
+ // 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");
+});
+
await test("vault: keyring wraps are sanitised β€” a stranger's spare fields never fold in", () => {
const s = vreduce(undefined, LOCK({ wrap: { ...W("w1"), evil: "payload", credId: 42 } }));
const w = s.keyring.wraps[0];
diff --git a/test/event-vectors/vault.json b/test/event-vectors/vault.json
index b356828..f1b4c3f 100644
--- a/test/event-vectors/vault.json
+++ b/test/event-vectors/vault.json
@@ -165,6 +165,31 @@
}
}
},
+ {
+ "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.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 }
+ ],
+ "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 }
+ ],
+ "keyring": {
+ "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": []
+ }
+ }
+ },
{
"name": "junk requests/grants/enrolls fold to no-ops on a locked vault",
"event_sequence": [
diff --git a/test/recovery-vector.json b/test/recovery-vector.json
new file mode 100644
index 0000000..e71e797
--- /dev/null
+++ b/test/recovery-vector.json
@@ -0,0 +1,7 @@
+{
+ "_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 3f19e47..326f346 100644
--- a/test/vaultcrypto.test.mjs
+++ b/test/vaultcrypto.test.mjs
@@ -6,6 +6,9 @@
// 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,
@@ -17,6 +20,8 @@ import {
unsealValue,
grantVaultKey,
acceptGrant,
+ wrapVaultPrivRecovery,
+ unwrapVaultPrivRecovery,
} from "../web/static/apps/vaultcrypto.js";
let failures = 0;
@@ -106,5 +111,37 @@ await test("the full grant ceremony: lock β†’ request β†’ grant β†’ finish β†’ u
await assert.rejects(() => acceptGrant(granted, 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 c2e402d..08cb2d8 100644
--- a/web/static/apps/vault.js
+++ b/web/static/apps/vault.js
@@ -108,7 +108,9 @@ function findByName(items, name) {
// spare fields). Return the cleaned object, or null. Pure; exported for tests.
export function wrapShape(w) {
if (!w || typeof w.id !== "string" || !w.id) return null;
- if (w.kind !== "passkey" && w.kind !== "keychain") 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 (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;
@@ -254,7 +256,10 @@ export const app = defineApp({
"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 || s.keyring.wraps.length <= 1) return s;
+ // 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,
log: logEntry(s.log, e, "revoke", gone.label),
@@ -1053,9 +1058,12 @@ function buildAccess(keyring, ctx, say) {
const li = document.createElement("li");
const who = document.createElement("span");
who.className = "vault-access-who";
- who.textContent = w.label || "someone";
- li.append(who, document.createTextNode(w.kind === "keychain" ? " Β· CLI key" : " Β· passkey"));
- if (wraps.length > 1) {
+ 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";
+ 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.
+ if (wraps.length > 1 && w.kind !== "recovery") {
const revoke = document.createElement("button");
revoke.type = "button";
revoke.className = "vault-btn vault-btn--danger";
diff --git a/web/static/apps/vaultcrypto.js b/web/static/apps/vaultcrypto.js
index 35799ba..1c1bbba 100644
--- a/web/static/apps/vaultcrypto.js
+++ b/web/static/apps/vaultcrypto.js
@@ -72,6 +72,54 @@ 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