eleven: offline vault reads — vault list/get -db against a raw instance DB (#970)
* web: the vault lock layer — passkey (Touch ID / Face ID) sealing for Vault values (#946) Values in a locked vault are ECIES-sealed to a dedicated vault public key; the private half exists only in keyring wraps openable by a passkey's WebAuthn PRF output (home's kekFromPRF pattern, fully client-side — zero server change). Adds seal to the public key with no unlock; reads cost one biometric, cached in memory for five minutes. Access moves by request/grant events through the same pure reducer. vaultcrypto.js reuses crypto.js's sealForPub/wrapThreadKey wire shapes, so the Go sidecar and CLI (#947, #948) speak it natively. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * eleven: vault verbs — list/get/set + Touch ID enroll/finish/approve (#947, #948) The CLI folds the same vault events the web reducer folds — the two are pinned to each other by test/event-vectors/vault.json (the Go fold in internal/vault and vault.js both fold every vector; the file is the spec). set seals to the vault public key with no unlock; get opens this machine's keychain wrap behind LocalAuthentication (Touch ID). Values never ride argv — hidden prompt, pipe, or -from-file. macOS gate scope (honest): LAContext + a login-keychain KEK — the Secure-Enclave-bound item needs a signed binary with entitlements, which would break plain go build; signed builds can upgrade later without changing the wrap format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * gofmt vault_test.go Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * eleven: offline vault reads — vault list/get -db against a raw instance DB (#949) The hard gate before the operator vault can replace sops: fold a vault straight out of a DB file (a /data/<host>.db copy or an S3 replica restore) with no server anywhere. internal/vault gains the offline reader (read-only sqlite open, users.public_key → member, wrapped thread key → unwrap with the ~/.eleven private key, fold the log); vault list/get grow a -db flag riding the same code paths, so Touch ID still gates sealed values through the same keychain account. The drill runs in CI: vault_offline_test.go seeds a real DB via db.go and reads it back with server code out of the loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+422
−13
eleven/vault.go+106 −13internal/vault/offline.go+132 −0vault_offline_test.go+184 −0
diff --git a/eleven/vault.go b/eleven/vault.go| index d4f5d08..670e783 100644 |
| --- a/eleven/vault.go |
| +++ b/eleven/vault.go |
| @@ -38,20 +38,36 @@ func runVault(spaces []*space, args []string) { |
| chat := fs.String("chat", "", "target a group chat's shared Vault (name substring, like `eleven chat`)") |
| 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") |
| fs.Usage = vaultUsage |
| _ = fs.Parse(rest) |
| rest = fs.Args() |
| + // Offline mode is deliberately read-only: writes belong on the live thread. |
| + if *dbPath != "" && verb != "list" && verb != "get" { |
| + die("only list and get work offline (-db) — %s needs the live space", verb) |
| + } |
| + |
| switch verb { |
| case "list": |
| + if *dbPath != "" { |
| + _, _, title, s := vaultOffline(spaces, *dbPath, *chat) |
| + vaultList(title, s) |
| + return |
| + } |
| sp, tid, title := vaultTarget(spaces, *chat) |
| vaultList(title, foldVault(sp, tid)) |
| case "get": |
| if len(rest) != 1 { |
| - die("usage: eleven vault get [-chat c] <name>") |
| + die("usage: eleven vault get [-chat c] [-db f] <name>") |
| + } |
| + if *dbPath != "" { |
| + host, tid, _, s := vaultOffline(spaces, *dbPath, *chat) |
| + vaultGet(host, tid, s, rest[0]) |
| + return |
| } |
| sp, tid, _ := vaultTarget(spaces, *chat) |
| - vaultGet(sp, tid, foldVault(sp, tid), rest[0]) |
| + vaultGet(spaceHost(sp.sess), tid, foldVault(sp, tid), rest[0]) |
| case "set": |
| if len(rest) != 1 { |
| die("usage: eleven vault set [-chat c] [-from-file f] <name>") |
| @@ -94,6 +110,9 @@ commands: |
| 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. |
| Your personal Vault is the default target. Values print to stdout; everything |
| else goes to stderr, so VAR="$(eleven vault get name)" stays clean. |
| @@ -138,6 +157,79 @@ func foldVault(sp *space, threadID string) vault.State { |
| return vault.FoldAll(events) |
| } |
| +// vaultOffline is the -db path (docs/locked-vaults.md "Offline restore — the |
| +// hard gate"): resolve and fold a vault from a raw instance DB file with no |
| +// server anywhere. The identity comes from ~/.eleven exactly as online — the |
| +// DB tells us which session file is a member (users.public_key); that |
| +// session's private key unwraps the thread key; Touch ID still gates sealed |
| +// values through the same keychain account the live path uses. |
| +func vaultOffline(spaces []*space, dbPath, chatNeedle string) (host, threadID, title string, s vault.State) { |
| + o, err := vault.OpenOffline(dbPath) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + defer o.Close() |
| + type hit struct { |
| + sp *space |
| + uid string |
| + } |
| + var hits []hit |
| + for _, sp := range spaces { |
| + uid, err := o.UserID(sp.sess.UserPub) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + if uid != "" { |
| + hits = append(hits, hit{sp, uid}) |
| + } |
| + } |
| + switch len(hits) { |
| + case 1: |
| + // exactly one identity opens this DB — the normal case |
| + case 0: |
| + die("none of this terminal's identities are members of %s — its users table has no matching public key", dbPath) |
| + default: |
| + die("several identities match %s — scope to one with -session", dbPath) |
| + } |
| + sp, uid := hits[0].sp, hits[0].uid |
| + threads, err := o.Threads(uid, sp.sess.UserPriv) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + needle := strings.ToLower(strings.TrimSpace(chatNeedle)) |
| + var found []vault.OfflineThread |
| + for _, t := range threads { |
| + if needle == "" { |
| + if t.Kind == "self" && t.App == "vault" { |
| + found = append(found, t) |
| + } |
| + } else if strings.Contains(strings.ToLower(t.Name), needle) || t.ID == chatNeedle { |
| + found = append(found, t) |
| + } |
| + } |
| + switch len(found) { |
| + case 1: |
| + // fall through |
| + case 0: |
| + if needle == "" { |
| + die("no personal Vault in %s — a group's vault needs -chat", dbPath) |
| + } |
| + die("no thread matches %q in %s", chatNeedle, dbPath) |
| + default: |
| + die("%q matches several threads in %s — be more specific", chatNeedle, dbPath) |
| + } |
| + t := found[0] |
| + state, err := o.FoldVault(t.ID, t.Key) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + name := t.Name |
| + if name == "" { |
| + name = "Vault" |
| + } |
| + return spaceHost(sp.sess), t.ID, name + " (offline: " + dbPath + ")", state |
| +} |
| + |
| func vaultList(title string, s vault.State) { |
| lock := "not locked — values are E2EE to members, with no biometric gate" |
| if s.Keyring != nil { |
| @@ -183,14 +275,14 @@ func vaultList(title string, s vault.State) { |
| } |
| } |
| -func vaultGet(sp *space, threadID string, s vault.State, name string) { |
| +func vaultGet(host, threadID string, s vault.State, name string) { |
| 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(sp, threadID, s) |
| + priv := unlockVaultPriv(host, threadID, s) |
| var env struct { |
| V string `json:"v"` |
| } |
| @@ -253,7 +345,7 @@ func vaultEnroll(sp *space, threadID string, s vault.State, label string) { |
| host, _ := os.Hostname() |
| label = strings.TrimSpace(sp.sess.Name + " — CLI @ " + host) |
| } |
| - kek, err := vaultKEK(kekAccount(sp, threadID), true) |
| + kek, err := vaultKEK(kekAccount(spaceHost(sp.sess), threadID), true) |
| if err != nil { |
| die("%v", err) |
| } |
| @@ -299,7 +391,7 @@ func vaultFinish(sp *space, threadID string, s vault.State) { |
| if req.Granted == "" { |
| die("your request hasn't been approved yet — someone who can unlock the vault has to approve it") |
| } |
| - kek, err := vaultKEK(kekAccount(sp, threadID), false) |
| + kek, err := vaultKEK(kekAccount(spaceHost(sp.sess), threadID), false) |
| if err != nil { |
| die("%v", err) |
| } |
| @@ -350,7 +442,7 @@ func vaultApprove(sp *space, threadID string, s vault.State, who string) { |
| die("several pending requests match: %s — name one", strings.Join(names, ", ")) |
| } |
| req := pending[0] |
| - priv := unlockVaultPriv(sp, threadID, s) |
| + priv := unlockVaultPriv(spaceHost(sp.sess), threadID, s) |
| granted, err := e2ee.WrapThreadKey(priv, req.Pub) |
| if err != nil { |
| die("%v", err) |
| @@ -364,7 +456,7 @@ func vaultApprove(sp *space, threadID string, s vault.State, who string) { |
| // unlockVaultPriv opens the vault private key with this machine's keychain KEK |
| // (the Touch ID prompt) by trying it against every keychain wrap — which wrap |
| // is ours needs no bookkeeping, a wrong KEK simply fails AES-GCM auth. |
| -func unlockVaultPriv(sp *space, threadID string, s vault.State) string { |
| +func unlockVaultPriv(host, threadID string, s vault.State) string { |
| if s.Keyring == nil { |
| die("this vault isn't locked — nothing to unlock") |
| } |
| @@ -377,7 +469,7 @@ func unlockVaultPriv(sp *space, threadID string, s vault.State) string { |
| if len(keychainWraps) == 0 { |
| die("no CLI keys are enrolled in this vault yet — `eleven vault enroll` to request one") |
| } |
| - kek, err := vaultKEK(kekAccount(sp, threadID), false) |
| + kek, err := vaultKEK(kekAccount(host, threadID), false) |
| if err != nil { |
| die("%v", err) |
| } |
| @@ -390,10 +482,11 @@ func unlockVaultPriv(sp *space, threadID string, s vault.State) string { |
| panic("unreachable") |
| } |
| -// kekAccount names this vault's KEK in the keychain — per space AND per |
| -// thread, so two vaults never share a key. |
| -func kekAccount(sp *space, threadID string) string { |
| - return spaceHost(sp.sess) + "/" + threadID |
| +// kekAccount names this vault's KEK in the keychain — per space host AND per |
| +// thread, so two vaults never share a key, and an OFFLINE read of the same |
| +// vault (same host + thread) finds the same key. |
| +func kekAccount(host, threadID string) string { |
| + return host + "/" + threadID |
| } |
| // wrapUnderKEK/openUnderKEK carry a private key (pkcs8, base64url) under the |
diff --git a/internal/vault/offline.go b/internal/vault/offline.go| new file mode 100644 |
| index 0000000..78deb07 |
| --- /dev/null |
| +++ b/internal/vault/offline.go |
| @@ -0,0 +1,132 @@ |
| +// 🤖 The offline vault reader (docs/locked-vaults.md "Offline restore — the |
| +// hard gate"): fold a vault straight out of a raw sqlite instance DB — a copy |
| +// of /data/<host>.db, or what the replication ladder streams to S3 — with no |
| +// server process anywhere. The sops vault's unbeatable property is working |
| +// with everything down; the operator vault matches it here, because all the |
| +// crypto was client-side to begin with: the DB holds only ciphertext rows plus |
| +// the member's WRAPPED thread key, and this reader supplies the member's |
| +// private key from their local session file. |
| +package vault |
| + |
| +import ( |
| + "database/sql" |
| + "fmt" |
| + |
| + _ "modernc.org/sqlite" |
| + |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// Offline is a read-only handle on an instance DB file. |
| +type Offline struct { |
| + db *sql.DB |
| +} |
| + |
| +// OpenOffline opens the DB file read-only (the reaper/updater DSN pattern — |
| +// note even mode=ro creates -wal/-shm beside the file if they're absent). |
| +func OpenOffline(path string) (*Offline, error) { |
| + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(3000)") |
| + if err != nil { |
| + return nil, err |
| + } |
| + db.SetMaxOpenConns(1) |
| + // Fail now, not on first query, if this isn't an instance DB at all. |
| + var n int |
| + if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE name IN ('users','threads','thread_keys','messages')").Scan(&n); err != nil || n < 4 { |
| + db.Close() |
| + return nil, fmt.Errorf("%s doesn't look like an lchat instance DB", path) |
| + } |
| + return &Offline{db: db}, nil |
| +} |
| + |
| +func (o *Offline) Close() error { return o.db.Close() } |
| + |
| +// UserID finds the member row matching a session's public key — how a reader |
| +// proves which identity file opens this DB. "" when this key isn't a member. |
| +func (o *Offline) UserID(userPubB64 string) (string, error) { |
| + var id string |
| + err := o.db.QueryRow("SELECT id FROM users WHERE public_key = ?", userPubB64).Scan(&id) |
| + if err == sql.ErrNoRows { |
| + return "", nil |
| + } |
| + return id, err |
| +} |
| + |
| +// OfflineThread is one thread this member holds a key for, name decrypted. |
| +type OfflineThread struct { |
| + ID string |
| + Kind string // self | dm | group |
| + Name string // group name / app title ("" when none) |
| + App string // a self app-thread's app id ("vault"), from the encrypted name_blob |
| + Key []byte // the unwrapped thread key |
| +} |
| + |
| +// Threads returns every thread the member holds a wrapped key for, unwrapping |
| +// each with the session private key. Threads whose key won't unwrap (rotated |
| +// membership edge cases) are skipped, not fatal. |
| +func (o *Offline) Threads(userID, userPrivB64 string) ([]OfflineThread, error) { |
| + rows, err := o.db.Query( |
| + `SELECT t.id, t.kind, COALESCE(t.name_blob, ''), tk.wrapped |
| + FROM threads t JOIN thread_keys tk ON tk.thread_id = t.id |
| + WHERE tk.user_id = ?`, userID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []OfflineThread |
| + for rows.Next() { |
| + var t OfflineThread |
| + var nameBlob, wrapped string |
| + if err := rows.Scan(&t.ID, &t.Kind, &nameBlob, &wrapped); err != nil { |
| + return nil, err |
| + } |
| + keyB64, err := e2ee.UnwrapThreadKey(wrapped, userPrivB64) |
| + if err != nil { |
| + continue |
| + } |
| + if t.Key, err = e2ee.ImportKey(keyB64); err != nil { |
| + continue |
| + } |
| + if nameBlob != "" { |
| + var n struct { |
| + Name string `json:"name"` |
| + Title string `json:"title"` |
| + App string `json:"app"` |
| + } |
| + if e2ee.DecryptJSON(t.Key, nameBlob, &n) == nil { |
| + t.Name, t.App = n.Name, n.App |
| + if t.Name == "" { |
| + t.Name = n.Title |
| + } |
| + } |
| + } |
| + out = append(out, t) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// FoldVault decrypts a thread's full message log (ascending) and folds its |
| +// vault events — the exact fold the live CLI and the web reducer run, so an |
| +// offline read can never disagree with the app. |
| +func (o *Offline) FoldVault(threadID string, key []byte) (State, error) { |
| + rows, err := o.db.Query("SELECT blob FROM messages WHERE thread_id = ? ORDER BY id ASC", threadID) |
| + if err != nil { |
| + return State{}, err |
| + } |
| + defer rows.Close() |
| + var s State |
| + for rows.Next() { |
| + var blob string |
| + if err := rows.Scan(&blob); err != nil { |
| + return State{}, err |
| + } |
| + var p map[string]any |
| + if e2ee.DecryptJSON(key, blob, &p) != nil { |
| + continue // tombstones, pre-membership history — not ours to read |
| + } |
| + if IsVaultEvent(p) { |
| + s = Fold(s, p) |
| + } |
| + } |
| + return s, rows.Err() |
| +} |
diff --git a/vault_offline_test.go b/vault_offline_test.go| new file mode 100644 |
| index 0000000..97b2b14 |
| --- /dev/null |
| +++ b/vault_offline_test.go |
| @@ -0,0 +1,184 @@ |
| +package main |
| + |
| +// 🤖 The offline restore drill, as a test (docs/locked-vaults.md "Offline |
| +// restore — the hard gate"): seed a REAL instance DB through db.go — a member |
| +// with a public key, a personal Vault app-thread with a wrapped thread key, |
| +// and a locked vault's event log, all sealed with the real e2ee crypto — then |
| +// read the whole thing back through internal/vault's offline reader with the |
| +// server code out of the loop, exactly what `eleven vault get -db` does when |
| +// every server is dead. If this passes, the recovery path is crypto-complete: |
| +// S3 replica + ~/.eleven key + keychain KEK = secrets. |
| + |
| +import ( |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/e2ee" |
| + "lchat/internal/vault" |
| +) |
| + |
| +func TestVaultOfflineRestore(t *testing.T) { |
| + dbPath := filepath.Join(t.TempDir(), "lchat.db") |
| + db, err := openDB(dbPath) |
| + if err != nil { |
| + t.Fatalf("openDB: %v", err) |
| + } |
| + |
| + // A member, exactly as join would leave them: keypair client-side, public |
| + // half in the roster. |
| + member, err := e2ee.GenerateUserKeypair() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.sql.Exec("INSERT INTO users (id, label, created_at, public_key) VALUES ('u1','',?,?)", |
| + time.Now().Unix(), member.PublicKey); err != nil { |
| + t.Fatalf("seed user: %v", err) |
| + } |
| + |
| + // Their personal Vault app-thread: the app id lives INSIDE the encrypted |
| + // name_blob (the server never learns which self-thread is a vault), and the |
| + // thread key reaches the member only as an ECIES wrap to their public key. |
| + rawKey, err := e2ee.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + keyB64 := e2ee.B64uEncode(rawKey) |
| + wrapped, err := e2ee.WrapThreadKey(keyB64, member.PublicKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nameBlob, err := e2ee.EncryptJSON(rawKey, map[string]string{"app": "vault", "title": "Vault"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now().Unix() |
| + if _, err := db.sql.Exec("INSERT INTO threads (id, kind, name_blob, created_at) VALUES ('t1','self',?,?)", nameBlob, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.sql.Exec("INSERT INTO thread_members (thread_id, user_id, created_at) VALUES ('t1','u1',?)", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.sql.Exec("INSERT INTO thread_keys (thread_id, user_id, wrapped) VALUES ('t1','u1',?)", wrapped); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The vault's life so far: a plaintext add, then a lock (re-sealing that |
| + // value and wrapping the vault key under a keychain-style KEK), then a |
| + // sealed add — the operator-vault shape. |
| + vaultKP, err := e2ee.GenerateUserKeypair() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + kek, err := e2ee.GenerateKey() // stands in for the Touch ID-guarded keychain KEK |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + privRaw, err := e2ee.B64uDecode(vaultKP.PrivateKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wrapBytes, err := e2ee.EncryptBytes(kek, privRaw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sealedWiFi, err := e2ee.SealForPub(vaultKP.PublicKey, map[string]string{"v": "hunter2"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sealedEnv, err := e2ee.SealForPub(vaultKP.PublicKey, map[string]string{"v": "LCHAT_ADMIN_TOKEN=abc\nADDR=:443\n"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + events := []map[string]any{ |
| + {"type": "vault.add", "id": "s1", "aid": "a1", "label": "WiFi", "value": "hunter2", "uid": "u1", "name": "Paul", "ts": 1000}, |
| + {"type": "vault.lock", "aid": "a2", "pub": vaultKP.PublicKey, |
| + "wrap": map[string]any{"id": "w1", "kind": "keychain", "label": "Paul — CLI", "wrapped": e2ee.B64uEncode(wrapBytes)}, |
| + "sealed": map[string]any{"s1": sealedWiFi}, "uid": "u1", "name": "Paul", "ts": 2000}, |
| + {"type": "vault.add", "id": "s2", "aid": "a3", "label": "router.env", "sealed": sealedEnv, "uid": "u1", "name": "Paul", "ts": 3000}, |
| + } |
| + for _, e := range events { |
| + blob, err := e2ee.EncryptJSON(rawKey, e) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.insertMessage("t1", blob, "author"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + // ---- the drill: server code out of the loop from here ------------------- |
| + |
| + o, err := vault.OpenOffline(dbPath) |
| + if err != nil { |
| + t.Fatalf("OpenOffline: %v", err) |
| + } |
| + defer o.Close() |
| + |
| + uid, err := o.UserID(member.PublicKey) |
| + if err != nil || uid != "u1" { |
| + t.Fatalf("UserID = %q, %v — want u1", uid, err) |
| + } |
| + if id, err := o.UserID("someone-elses-key"); err != nil || id != "" { |
| + t.Fatalf("a foreign key must not match a member (got %q, %v)", id, err) |
| + } |
| + |
| + threads, err := o.Threads(uid, member.PrivateKey) |
| + if err != nil { |
| + t.Fatalf("Threads: %v", err) |
| + } |
| + var vt *vault.OfflineThread |
| + for i := range threads { |
| + if threads[i].App == "vault" && threads[i].Kind == "self" { |
| + vt = &threads[i] |
| + } |
| + } |
| + if vt == nil { |
| + t.Fatalf("no vault app-thread found offline; threads: %+v", threads) |
| + } |
| + |
| + s, err := o.FoldVault(vt.ID, vt.Key) |
| + if err != nil { |
| + t.Fatalf("FoldVault: %v", err) |
| + } |
| + if s.Keyring == nil || s.Keyring.Pub != vaultKP.PublicKey { |
| + t.Fatalf("keyring didn't fold: %+v", s.Keyring) |
| + } |
| + if len(s.Items) != 2 { |
| + t.Fatalf("want 2 items, got %d", len(s.Items)) |
| + } |
| + wifi := s.FindItem("wifi") |
| + if wifi == nil || wifi.Sealed == "" || wifi.Value != "" { |
| + t.Fatalf("the lock event should have sealed WiFi: %+v", wifi) |
| + } |
| + |
| + // The get path: keychain KEK → vault private key → unseal both values. |
| + wrapRaw, err := e2ee.B64uDecode(s.Keyring.Wraps[0].Wrapped) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + privOut, err := e2ee.DecryptBytes(kek, wrapRaw) |
| + if err != nil { |
| + t.Fatalf("KEK didn't open the wrap: %v", err) |
| + } |
| + privB64 := e2ee.B64uEncode(privOut) |
| + var got struct { |
| + V string `json:"v"` |
| + } |
| + if err := e2ee.OpenSealed(wifi.Sealed, privB64, &got); err != nil || got.V != "hunter2" { |
| + t.Fatalf("unseal WiFi = %q, %v", got.V, err) |
| + } |
| + env := s.FindItem("router.env") |
| + if env == nil { |
| + t.Fatal("router.env missing offline") |
| + } |
| + if err := e2ee.OpenSealed(env.Sealed, privB64, &got); err != nil || got.V != "LCHAT_ADMIN_TOKEN=abc\nADDR=:443\n" { |
| + t.Fatalf("unseal router.env = %q, %v", got.V, err) |
| + } |
| + |
| + // And the failure mode that matters: a WRONG kek opens nothing. |
| + badKek, _ := e2ee.GenerateKey() |
| + if _, err := e2ee.DecryptBytes(badKek, wrapRaw); err == nil { |
| + t.Fatal("a wrong KEK must never open the vault key wrap") |
| + } |
| +} |