eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// checkpoint.go — tamper-evidence, part 1: chained, signed Merkle checkpoints
2// over the append-only event journal (docs/tamper-evidence.md, issue #573).
3//
4// The message table mutates on purpose (edits UPDATE in place, unsend hard-
5// DELETEs), so the unit of commitment is the event_journal: every insert /
6// edit / delete appends a row in the same transaction (db.go). A background
7// sweep periodically folds all new journal rows into a checkpoint: a Merkle
8// root over the batch, a chain hash committing to the previous checkpoint,
9// and an Ed25519 signature over the encoded payload (the release.go signed-
10// manifest pattern — sign the base64 string, so there is no JSON
11// canonicalization to get wrong).
12//
13// The signing key is NOT the security boundary — a malicious host runs the
14// process and holds the seed. The property is chain continuity: a host can
15// forge a fresh chain, but not one that both extends a head someone already
16// witnessed (a client, the escrow replica) and omits what that head committed
17// to. The seed therefore lives in the DB config by default, so it survives
18// every path that moves instance state (hibernation wake, VACUUM-INTO
19// migration, escrow restore) — a key that silently changes on routine ops
20// would make every legitimate restore read as forgery. The trade: anyone
21// holding a DB copy can sign as the instance; acceptable, because the
22// signature is attribution, not the security (docs/tamper-evidence.md).
23// LCHAT_CHECKPOINT_KEY_FILE keeps the seed off-DB for operators who'd rather
24// carry the file themselves. Each checkpoint embeds its public key, so a key
25// change is an explicit, investigable event — never a silent false alarm.
26package main
27
28import (
29 "crypto/ed25519"
30 "crypto/rand"
31 "crypto/sha256"
32 "encoding/base64"
33 "encoding/hex"
34 "encoding/json"
35 "errors"
36 "fmt"
37 "log"
38 "net/http"
39 "os"
40 "strconv"
41 "strings"
42 "time"
43)
44
45// Domain separators: leaves, interior nodes, and the checkpoint chain must
46// never be confusable with each other or with any other protocol hash.
47const (
48 ckptLeafDomain = "lchat-ckpt-leaf\n"
49 ckptNodeDomain = "lchat-ckpt-node\n"
50 ckptChainDomain = "lchat-ckpt-chain\n"
51)
52
53// How often the sweep looks for new journal rows, and how many rows go into
54// one checkpoint at most (a busy backlog folds into several checkpoints).
55const (
56 checkpointInterval = 5 * time.Minute
57 checkpointBatchSize = 10000
58)
59
60// journalLeafHash is the canonical leaf encoding. Fields are newline-joined
61// in fixed order; thread_id goes last so the one non-fixed-width field can't
62// create an ambiguous boundary. Changing this encoding breaks every existing
63// chain — it is wire format, not an implementation detail. The fields after
64// `kind` are that kind's detail record: future kinds (roster/key events, the
65// key-transparency slice) define their own fields there without touching the
66// encoding of existing kinds.
67func journalLeafHash(r JournalRow) [32]byte {
68 enc := ckptLeafDomain +
69 strconv.FormatInt(r.Seq, 10) + "\n" +
70 strconv.FormatInt(r.TS, 10) + "\n" +
71 r.Kind + "\n" +
72 strconv.FormatInt(r.MsgID, 10) + "\n" +
73 r.BlobHash + "\n" +
74 r.ThreadID
75 return sha256.Sum256([]byte(enc))
76}
77
78// merkleRoot builds an RFC 6962-style tree: split at the largest power of two
79// strictly less than n, recurse, hash the pair under the node domain. That
80// shape keeps inclusion proofs (a later slice) standard. Must never be called
81// with zero leaves — a checkpoint only exists because journal rows do.
82func merkleRoot(leaves [][32]byte) [32]byte {
83 if len(leaves) == 1 {
84 return leaves[0]
85 }
86 k := 1
87 for k*2 < len(leaves) {
88 k *= 2
89 }
90 l, r := merkleRoot(leaves[:k]), merkleRoot(leaves[k:])
91 return sha256.Sum256(append(append([]byte(ckptNodeDomain), l[:]...), r[:]...))
92}
93
94// merkleNode hashes an interior node (its two child hashes). Split out so the
95// audit-path recompute and merkleRoot agree byte for byte.
96func merkleNode(l, r [32]byte) [32]byte {
97 return sha256.Sum256(append(append([]byte(ckptNodeDomain), l[:]...), r[:]...))
98}
99
100// merkleAuditPath returns the RFC 6962 inclusion proof for leaf index m in a
101// tree of these leaves: the sibling subtree roots bottom-up. A verifier
102// recombines them with the leaf (rootFromAuditPath) to reach the committed
103// root — proving the leaf is in the tree without the server handing over the
104// whole log. Same power-of-two split as merkleRoot, so proofs are standard.
105func merkleAuditPath(leaves [][32]byte, m int) [][32]byte {
106 n := len(leaves)
107 if n <= 1 {
108 return nil
109 }
110 k := 1
111 for k*2 < n {
112 k *= 2
113 }
114 if m < k {
115 return append(merkleAuditPath(leaves[:k], m), merkleRoot(leaves[k:]))
116 }
117 return append(merkleAuditPath(leaves[k:], m-k), merkleRoot(leaves[:k]))
118}
119
120// rootFromAuditPath recomputes the Merkle root from a leaf, its index m in a
121// tree of n leaves, and its audit path — the verifier's side of
122// merkleAuditPath. It mirrors that function's recursion: the LAST path element
123// is the sibling at this level, and the rest belongs to the subtree the leaf
124// is in, so m/n alone decide each sibling's left/right position (no per-step
125// direction bit). The JS and Swift clients mirror this; a Go test pins all
126// three against each other for every (n, m).
127func rootFromAuditPath(leaf [32]byte, m, n int, path [][32]byte) ([32]byte, bool) {
128 if m < 0 || m >= n {
129 return [32]byte{}, false
130 }
131 if n == 1 {
132 return leaf, len(path) == 0
133 }
134 if len(path) == 0 {
135 return [32]byte{}, false
136 }
137 k := 1
138 for k*2 < n {
139 k *= 2
140 }
141 sibling := path[len(path)-1]
142 rest := path[:len(path)-1]
143 if m < k {
144 left, ok := rootFromAuditPath(leaf, m, k, rest)
145 if !ok {
146 return [32]byte{}, false
147 }
148 return merkleNode(left, sibling), true
149 }
150 right, ok := rootFromAuditPath(leaf, m-k, n-k, rest)
151 if !ok {
152 return [32]byte{}, false
153 }
154 return merkleNode(sibling, right), true
155}
156
157// chainHash commits a checkpoint to its predecessor. prevChain is "" for the
158// genesis checkpoint. seq_to rides inside so two chains can't agree while
159// disagreeing about how much history a head covers.
160func chainHash(prevChain, root string, seqTo int64) string {
161 enc := ckptChainDomain + prevChain + "\n" + root + "\n" + strconv.FormatInt(seqTo, 10)
162 sum := sha256.Sum256([]byte(enc))
163 return hex.EncodeToString(sum[:])
164}
165
166// ckptPayload is what gets signed. Like releaseManifest, the signature covers
167// the base64 of this JSON — verify first, decode after.
168type ckptPayload struct {
169 SeqFrom int64 `json:"seq_from"`
170 SeqTo int64 `json:"seq_to"`
171 Root string `json:"root"` // hex Merkle root over leaves [seq_from..seq_to]
172 PrevChain string `json:"prev_chain"` // hex chain of the predecessor ("" = genesis)
173 Chain string `json:"chain"` // hex chainHash(prev_chain, root, seq_to)
174 TS int64 `json:"ts"` // unix seconds, sign time
175 // The signing key's public half, so every checkpoint names its own key: a
176 // verifier walking the chain sees a key CHANGE as an explicit event to
177 // investigate, instead of old checkpoints suddenly failing against the
178 // instance's current key.
179 Pub string `json:"pub"` // base64 ed25519 public key
180}
181
182// signedCheckpoint mirrors release.go's signedManifest wire shape.
183type signedCheckpoint struct {
184 Payload string `json:"payload"` // base64(JSON ckptPayload)
185 Sig string `json:"sig"` // base64(ed25519 sig over the payload STRING)
186}
187
188func signCheckpoint(priv ed25519.PrivateKey, p ckptPayload) (string, error) {
189 body, err := json.Marshal(p)
190 if err != nil {
191 return "", err
192 }
193 b64 := base64.StdEncoding.EncodeToString(body)
194 wrapper, err := json.Marshal(signedCheckpoint{
195 Payload: b64,
196 Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64))),
197 })
198 return string(wrapper), err
199}
200
201// verifyCheckpoint checks the signature over the encoded payload string, then
202// decodes. It verifies attribution, not history — recomputing roots against
203// the journal and walking the chain is the verifier's job (see tests; clients
204// and monitors get this in later slices).
205func verifyCheckpoint(pub ed25519.PublicKey, wrapper string) (ckptPayload, error) {
206 var sc signedCheckpoint
207 if err := json.Unmarshal([]byte(wrapper), &sc); err != nil {
208 return ckptPayload{}, err
209 }
210 sig, err := base64.StdEncoding.DecodeString(sc.Sig)
211 if err != nil {
212 return ckptPayload{}, err
213 }
214 if !ed25519.Verify(pub, []byte(sc.Payload), sig) {
215 return ckptPayload{}, errors.New("bad checkpoint signature")
216 }
217 body, err := base64.StdEncoding.DecodeString(sc.Payload)
218 if err != nil {
219 return ckptPayload{}, err
220 }
221 var p ckptPayload
222 if err := json.Unmarshal(body, &p); err != nil {
223 return ckptPayload{}, err
224 }
225 if p.SeqFrom <= 0 || p.SeqTo < p.SeqFrom || p.Root == "" || p.Chain == "" {
226 return ckptPayload{}, errors.New("malformed checkpoint payload")
227 }
228 // The payload must name the key that signed it — a payload claiming a
229 // different key than the one whose signature just verified is confused or
230 // hostile either way.
231 if p.Pub != base64.StdEncoding.EncodeToString(pub) {
232 return ckptPayload{}, errors.New("checkpoint payload names a different key")
233 }
234 return p, nil
235}
236
237// checkpointKey lazily loads (or mints) the per-instance signing key: from
238// LCHAT_CHECKPOINT_KEY_FILE when set, else from the DB config (the default —
239// the seed must ride hibernation wakes and VACUUM-INTO migrations, which move
240// only the DB). Only success is cached: a transient read failure must not
241// disable checkpointing for the process lifetime.
242func (s *Server) checkpointKey() (ed25519.PrivateKey, error) {
243 s.ckptMu.Lock()
244 defer s.ckptMu.Unlock()
245 if s.ckptKey != nil {
246 return s.ckptKey, nil
247 }
248 var priv ed25519.PrivateKey
249 var err error
250 if s.ckptKeyPath != "" {
251 priv, err = loadOrCreateCheckpointKey(s.ckptKeyPath)
252 } else {
253 priv, err = s.db.ensureCheckpointSeed()
254 }
255 if err != nil {
256 return nil, err
257 }
258 s.ckptKey = priv
259 return priv, nil
260}
261
262// loadOrCreateCheckpointKey reads a base64 Ed25519 seed from path, minting one
263// on first use (temp-file + rename, so a crash mid-write can't leave a
264// truncated seed). Same storage shape as the release signing key (release.go).
265// A corrupt existing file is a loud error, never silently regenerated — a new
266// key mid-chain is exactly the event this feature exists to make visible.
267func loadOrCreateCheckpointKey(path string) (ed25519.PrivateKey, error) {
268 b, err := os.ReadFile(path)
269 if errors.Is(err, os.ErrNotExist) {
270 _, priv, gerr := ed25519.GenerateKey(rand.Reader)
271 if gerr != nil {
272 return nil, gerr
273 }
274 enc := base64.StdEncoding.EncodeToString(priv.Seed())
275 tmp := path + ".tmp"
276 if werr := os.WriteFile(tmp, []byte(enc+"\n"), 0o600); werr != nil {
277 return nil, fmt.Errorf("write checkpoint key: %w", werr)
278 }
279 if rerr := os.Rename(tmp, path); rerr != nil {
280 return nil, fmt.Errorf("write checkpoint key: %w", rerr)
281 }
282 return priv, nil
283 }
284 if err != nil {
285 return nil, err
286 }
287 return checkpointSeedKey(strings.TrimSpace(string(b)), path)
288}
289
290// checkpointSeedKey turns a stored base64 seed back into a key, shared by the
291// file and DB storage paths.
292func checkpointSeedKey(enc, what string) (ed25519.PrivateKey, error) {
293 seed, err := base64.StdEncoding.DecodeString(enc)
294 if err != nil {
295 return nil, fmt.Errorf("checkpoint key %s: %w", what, err)
296 }
297 if len(seed) != ed25519.SeedSize {
298 return nil, fmt.Errorf("checkpoint key %s: want %d-byte seed, got %d", what, ed25519.SeedSize, len(seed))
299 }
300 return ed25519.NewKeyFromSeed(seed), nil
301}
302
303// runCheckpointer is the ticker; tests call sweepCheckpoint directly (the
304// runScheduler/sweepDueScheduled split, main.go).
305func (s *Server) runCheckpointer() {
306 for range time.Tick(checkpointInterval) {
307 s.sweepCheckpoint()
308 }
309}
310
311// sweepCheckpoint folds new journal rows into checkpoints until the backlog is
312// drained, returning how many checkpoints it wrote. Zero new rows → zero work:
313// an idle instance's chain simply doesn't grow (no heartbeat checkpoints —
314// "nothing happened" is exactly what an unchanged head attests). The mutex
315// (plus the UNIQUE index on checkpoints.seq_from) keeps a second caller from
316// forking the chain — two checkpoints claiming the same predecessor would
317// read as tampering to a verifier.
318func (s *Server) sweepCheckpoint() int {
319 s.ckptSweepMu.Lock()
320 defer s.ckptSweepMu.Unlock()
321 prev, havePrev, err := s.db.latestCheckpoint()
322 if err != nil {
323 log.Printf("checkpoint latest: %v", err)
324 return 0
325 }
326 after := int64(0)
327 prevChain := ""
328 if havePrev {
329 after, prevChain = prev.SeqTo, prev.Chain
330 }
331 wrote := 0
332 for {
333 rows, err := s.db.journalAfter(after, checkpointBatchSize)
334 if err != nil {
335 log.Printf("checkpoint journal: %v", err)
336 return wrote
337 }
338 if len(rows) == 0 {
339 return wrote
340 }
341 leaves := make([][32]byte, len(rows))
342 for i, r := range rows {
343 leaves[i] = journalLeafHash(r)
344 }
345 root := merkleRoot(leaves)
346 priv, err := s.checkpointKey()
347 if err != nil {
348 log.Printf("checkpoint key: %v", err)
349 return wrote
350 }
351 p := ckptPayload{
352 SeqFrom: rows[0].Seq,
353 SeqTo: rows[len(rows)-1].Seq,
354 Root: hex.EncodeToString(root[:]),
355 PrevChain: prevChain,
356 TS: time.Now().Unix(),
357 Pub: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
358 }
359 p.Chain = chainHash(p.PrevChain, p.Root, p.SeqTo)
360 signed, err := signCheckpoint(priv, p)
361 if err != nil {
362 log.Printf("checkpoint sign: %v", err)
363 return wrote
364 }
365 if err := s.db.insertCheckpoint(p, signed); err != nil {
366 log.Printf("checkpoint store: %v", err)
367 return wrote
368 }
369 wrote++
370 // Carry the new head forward instead of re-reading it — the loop must
371 // not depend on observing its own insert through the DB.
372 after, prevChain = p.SeqTo, p.Chain
373 if len(rows) < checkpointBatchSize {
374 return wrote
375 }
376 }
377}
378
379// getCheckpoints serves the chain, member-token-gated: the chain leaks
380// activity volume and timing, so it is for members and monitors (who hold
381// member credentials), not the public. `after` is a checkpoint row id — a
382// verifier keeps its last-seen head and pages forward from it. Each
383// checkpoint's payload embeds its own public key, so the chain stays
384// verifiable even when the current key is unavailable — key trouble degrades
385// public_key to "", it never withholds the evidence.
386func (s *Server) getCheckpoints(w http.ResponseWriter, r *http.Request) {
387 token := r.URL.Query().Get("token")
388 if !s.tokenOK(token) {
389 httpError(w, http.StatusForbidden, "invalid invite")
390 return
391 }
392 after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
393 cps, err := s.db.checkpointsAfter(after, 100)
394 if err != nil {
395 httpError(w, http.StatusInternalServerError, "db error")
396 return
397 }
398 pub := ""
399 if priv, err := s.checkpointKey(); err != nil {
400 log.Printf("checkpoint key: %v", err)
401 } else {
402 pub = base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey))
403 }
404 writeJSON(w, http.StatusOK, map[string]any{
405 "checkpoints": cps,
406 "public_key": pub,
407 })
408}
409
410// inclusionProof is one journal event proven to be in a committed checkpoint's
411// Merkle tree. The client recomputes the leaf from the row fields, recombines
412// the audit path to a root, and checks that root against the checkpoint it
413// already trusts via the signed chain — so it can confirm a message it holds
414// is committed to the tamper-evident log without the server handing over the
415// whole log.
416type inclusionProof struct {
417 Seq int64 `json:"seq"`
418 TS int64 `json:"ts"`
419 Kind string `json:"kind"`
420 MsgID int64 `json:"msg_id"`
421 ThreadID string `json:"thread_id"`
422 BlobHash string `json:"blob_hash"`
423 CheckpointID int64 `json:"checkpoint_id"`
424 Root string `json:"root"`
425 LeafIndex int `json:"leaf_index"`
426 TreeSize int `json:"tree_size"`
427 AuditPath []string `json:"audit_path"` // hex sibling hashes, bottom-up
428}
429
430// getCheckpointProof serves Merkle inclusion proofs for a message: every
431// journal event of that message id (insert / edit / delete) that a checkpoint
432// already covers, each with its audit path. A client sampling the message
433// matches the proof whose blob_hash equals its held ciphertext's hash and
434// verifies it against a trusted root. `committed_to` is the highest checkpointed
435// seq, so a client can tell "not proven because too recent" (seq beyond it)
436// from "not proven because missing" (tampering). Member-gated like messages.
437func (s *Server) getCheckpointProof(w http.ResponseWriter, r *http.Request) {
438 token := r.URL.Query().Get("token")
439 if !s.tokenOK(token) {
440 httpError(w, http.StatusForbidden, "invalid invite")
441 return
442 }
443 msgID, err := strconv.ParseInt(r.URL.Query().Get("msg"), 10, 64)
444 if err != nil || msgID <= 0 {
445 httpError(w, http.StatusBadRequest, "bad msg id")
446 return
447 }
448 journalRows, err := s.db.journalRowsForMsg(msgID)
449 if err != nil {
450 httpError(w, http.StatusInternalServerError, "db error")
451 return
452 }
453 head, hasHead, err := s.db.latestCheckpoint()
454 if err != nil {
455 httpError(w, http.StatusInternalServerError, "db error")
456 return
457 }
458 committedTo := int64(0)
459 if hasHead {
460 committedTo = head.SeqTo
461 }
462 proofs := []inclusionProof{}
463 for _, jr := range journalRows {
464 cp, covered, err := s.db.checkpointCovering(jr.Seq)
465 if err != nil {
466 httpError(w, http.StatusInternalServerError, "db error")
467 return
468 }
469 if !covered {
470 continue // too recent to be checkpointed yet — not an error
471 }
472 proof, ok, err := s.buildInclusionProof(jr, cp)
473 if err != nil {
474 httpError(w, http.StatusInternalServerError, "db error")
475 return
476 }
477 if ok {
478 proofs = append(proofs, proof)
479 }
480 }
481 writeJSON(w, http.StatusOK, map[string]any{
482 "proofs": proofs,
483 "committed_to": committedTo,
484 })
485}
486
487// buildInclusionProof rebuilds the covering checkpoint's Merkle tree from the
488// journal and extracts the audit path for jr's leaf. ok=false only if the
489// journal row somehow isn't in its own checkpoint's range (shouldn't happen —
490// the journal is append-only — but never serve a bogus proof).
491func (s *Server) buildInclusionProof(jr JournalRow, cp Checkpoint) (inclusionProof, bool, error) {
492 rows, err := s.db.journalRange(cp.SeqFrom, cp.SeqTo)
493 if err != nil {
494 return inclusionProof{}, false, err
495 }
496 idx := -1
497 leaves := make([][32]byte, len(rows))
498 for i, row := range rows {
499 leaves[i] = journalLeafHash(row)
500 if row.Seq == jr.Seq {
501 idx = i
502 }
503 }
504 if idx < 0 {
505 return inclusionProof{}, false, nil
506 }
507 path := merkleAuditPath(leaves, idx)
508 hexPath := make([]string, len(path))
509 for i, h := range path {
510 hexPath[i] = hex.EncodeToString(h[:])
511 }
512 return inclusionProof{
513 Seq: jr.Seq, TS: jr.TS, Kind: jr.Kind, MsgID: jr.MsgID,
514 ThreadID: jr.ThreadID, BlobHash: jr.BlobHash,
515 CheckpointID: cp.ID, Root: cp.Root, LeafIndex: idx, TreeSize: len(leaves),
516 AuditPath: hexPath,
517 }, true, nil
518}
519