| 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. |
| 9 | package vault |
| 10 | |
| 11 | import ( |
| 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. |
| 21 | type 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). |
| 27 | func 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 | |
| 42 | func (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. |
| 46 | func (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. |
| 56 | type 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. |
| 67 | func (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. |
| 111 | func (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 | |