// 🤖 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/.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() }