| 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. |
| 26 | package main |
| 27 | |
| 28 | import ( |
| 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. |
| 47 | const ( |
| 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). |
| 55 | const ( |
| 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. |
| 67 | func 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. |
| 82 | func 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. |
| 96 | func 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. |
| 105 | func 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). |
| 127 | func 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. |
| 160 | func 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. |
| 168 | type 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. |
| 183 | type signedCheckpoint struct { |
| 184 | Payload string `json:"payload"` // base64(JSON ckptPayload) |
| 185 | Sig string `json:"sig"` // base64(ed25519 sig over the payload STRING) |
| 186 | } |
| 187 | |
| 188 | func 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). |
| 205 | func 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. |
| 242 | func (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. |
| 267 | func 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. |
| 292 | func 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). |
| 305 | func (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. |
| 318 | func (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. |
| 386 | func (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. |
| 416 | type 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. |
| 437 | func (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). |
| 491 | func (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 | |