eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– The offline vault reader (docs/locked-vaults.md "Offline restore β€” the
2// hard gate"): fold a vault straight out of a raw sqlite instance DB β€” a copy
3// of /data/<host>.db, or what the replication ladder streams to S3 β€” with no
4// server process anywhere. The sops vault's unbeatable property is working
5// with everything down; the operator vault matches it here, because all the
6// crypto was client-side to begin with: the DB holds only ciphertext rows plus
7// the member's WRAPPED thread key, and this reader supplies the member's
8// private key from their local session file.
9package vault
10
11import (
12 "database/sql"
13 "fmt"
14
15 _ "modernc.org/sqlite"
16
17 "lchat/internal/e2ee"
18)
19
20// Offline is a read-only handle on an instance DB file.
21type Offline struct {
22 db *sql.DB
23}
24
25// OpenOffline opens the DB file read-only (the reaper/updater DSN pattern β€”
26// note even mode=ro creates -wal/-shm beside the file if they're absent).
27func OpenOffline(path string) (*Offline, error) {
28 db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(3000)")
29 if err != nil {
30 return nil, err
31 }
32 db.SetMaxOpenConns(1)
33 // Fail now, not on first query, if this isn't an instance DB at all.
34 var n int
35 if err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE name IN ('users','threads','thread_keys','messages')").Scan(&n); err != nil || n < 4 {
36 db.Close()
37 return nil, fmt.Errorf("%s doesn't look like an lchat instance DB", path)
38 }
39 return &Offline{db: db}, nil
40}
41
42func (o *Offline) Close() error { return o.db.Close() }
43
44// UserID finds the member row matching a session's public key β€” how a reader
45// proves which identity file opens this DB. "" when this key isn't a member.
46func (o *Offline) UserID(userPubB64 string) (string, error) {
47 var id string
48 err := o.db.QueryRow("SELECT id FROM users WHERE public_key = ?", userPubB64).Scan(&id)
49 if err == sql.ErrNoRows {
50 return "", nil
51 }
52 return id, err
53}
54
55// OfflineThread is one thread this member holds a key for, name decrypted.
56type OfflineThread struct {
57 ID string
58 Kind string // self | dm | group
59 Name string // group name / app title ("" when none)
60 App string // a self app-thread's app id ("vault"), from the encrypted name_blob
61 Key []byte // the unwrapped thread key
62}
63
64// Threads returns every thread the member holds a wrapped key for, unwrapping
65// each with the session private key. Threads whose key won't unwrap (rotated
66// membership edge cases) are skipped, not fatal.
67func (o *Offline) Threads(userID, userPrivB64 string) ([]OfflineThread, error) {
68 rows, err := o.db.Query(
69 `SELECT t.id, t.kind, COALESCE(t.name_blob, ''), tk.wrapped
70 FROM threads t JOIN thread_keys tk ON tk.thread_id = t.id
71 WHERE tk.user_id = ?`, userID)
72 if err != nil {
73 return nil, err
74 }
75 defer rows.Close()
76 var out []OfflineThread
77 for rows.Next() {
78 var t OfflineThread
79 var nameBlob, wrapped string
80 if err := rows.Scan(&t.ID, &t.Kind, &nameBlob, &wrapped); err != nil {
81 return nil, err
82 }
83 keyB64, err := e2ee.UnwrapThreadKey(wrapped, userPrivB64)
84 if err != nil {
85 continue
86 }
87 if t.Key, err = e2ee.ImportKey(keyB64); err != nil {
88 continue
89 }
90 if nameBlob != "" {
91 var n struct {
92 Name string `json:"name"`
93 Title string `json:"title"`
94 App string `json:"app"`
95 }
96 if e2ee.DecryptJSON(t.Key, nameBlob, &n) == nil {
97 t.Name, t.App = n.Name, n.App
98 if t.Name == "" {
99 t.Name = n.Title
100 }
101 }
102 }
103 out = append(out, t)
104 }
105 return out, rows.Err()
106}
107
108// FoldVault decrypts a thread's full message log (ascending) and folds its
109// vault events β€” the exact fold the live CLI and the web reducer run, so an
110// offline read can never disagree with the app.
111func (o *Offline) FoldVault(threadID string, key []byte) (State, error) {
112 rows, err := o.db.Query("SELECT blob FROM messages WHERE thread_id = ? ORDER BY id ASC", threadID)
113 if err != nil {
114 return State{}, err
115 }
116 defer rows.Close()
117 var s State
118 for rows.Next() {
119 var blob string
120 if err := rows.Scan(&blob); err != nil {
121 return State{}, err
122 }
123 var p map[string]any
124 if e2ee.DecryptJSON(key, blob, &p) != nil {
125 continue // tombstones, pre-membership history β€” not ours to read
126 }
127 if IsVaultEvent(p) {
128 s = Fold(s, p)
129 }
130 }
131 return s, rows.Err()
132}
133