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>
13 files changed,
+69
−444
changelog/2026-07-25-vault-recovery-is-a-passkey.md+4 −0docs/locked-vaults.md+3 −3docs/operator-vault-migration.md+4 −0eleven/recovery.go+0 −64eleven/recovery_test.go+0 −80eleven/vault.go+27 −156internal/vault/vault.go+7 −10test/apps.test.mjs+4 −8test/event-vectors/vault.json+6 −11test/recovery-vector.json+0 −7test/vaultcrypto.test.mjs+0 −37web/static/apps/vault.js+14 −20web/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 |