eleven / messenger Public

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>
Paul Campbell 954b760b56fdd4218cc3fb342393cc09bd646ea2 parent e6c7acc
3 files changed, +422 −13
  • eleven/vault.go +106 −13
  • internal/vault/offline.go +132 −0
  • vault_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")
+ }
+}