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>
10 files changed,
+464
−20
eleven/recovery.go+64 −0eleven/recovery_test.go+80 −0eleven/vault.go+167 −10internal/vault/vault.go+8 −5test/apps.test.mjs+15 −0test/event-vectors/vault.json+25 −0test/recovery-vector.json+7 −0test/vaultcrypto.test.mjs+37 −0web/static/apps/vault.js+13 −5web/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 |