// checkpoint.go — tamper-evidence, part 1: chained, signed Merkle checkpoints // over the append-only event journal (docs/tamper-evidence.md, issue #573). // // The message table mutates on purpose (edits UPDATE in place, unsend hard- // DELETEs), so the unit of commitment is the event_journal: every insert / // edit / delete appends a row in the same transaction (db.go). A background // sweep periodically folds all new journal rows into a checkpoint: a Merkle // root over the batch, a chain hash committing to the previous checkpoint, // and an Ed25519 signature over the encoded payload (the release.go signed- // manifest pattern — sign the base64 string, so there is no JSON // canonicalization to get wrong). // // The signing key is NOT the security boundary — a malicious host runs the // process and holds the seed. The property is chain continuity: a host can // forge a fresh chain, but not one that both extends a head someone already // witnessed (a client, the escrow replica) and omits what that head committed // to. The seed therefore lives in the DB config by default, so it survives // every path that moves instance state (hibernation wake, VACUUM-INTO // migration, escrow restore) — a key that silently changes on routine ops // would make every legitimate restore read as forgery. The trade: anyone // holding a DB copy can sign as the instance; acceptable, because the // signature is attribution, not the security (docs/tamper-evidence.md). // LCHAT_CHECKPOINT_KEY_FILE keeps the seed off-DB for operators who'd rather // carry the file themselves. Each checkpoint embeds its public key, so a key // change is an explicit, investigable event — never a silent false alarm. package main import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "log" "net/http" "os" "strconv" "strings" "time" ) // Domain separators: leaves, interior nodes, and the checkpoint chain must // never be confusable with each other or with any other protocol hash. const ( ckptLeafDomain = "lchat-ckpt-leaf\n" ckptNodeDomain = "lchat-ckpt-node\n" ckptChainDomain = "lchat-ckpt-chain\n" ) // How often the sweep looks for new journal rows, and how many rows go into // one checkpoint at most (a busy backlog folds into several checkpoints). const ( checkpointInterval = 5 * time.Minute checkpointBatchSize = 10000 ) // journalLeafHash is the canonical leaf encoding. Fields are newline-joined // in fixed order; thread_id goes last so the one non-fixed-width field can't // create an ambiguous boundary. Changing this encoding breaks every existing // chain — it is wire format, not an implementation detail. The fields after // `kind` are that kind's detail record: future kinds (roster/key events, the // key-transparency slice) define their own fields there without touching the // encoding of existing kinds. func journalLeafHash(r JournalRow) [32]byte { enc := ckptLeafDomain + strconv.FormatInt(r.Seq, 10) + "\n" + strconv.FormatInt(r.TS, 10) + "\n" + r.Kind + "\n" + strconv.FormatInt(r.MsgID, 10) + "\n" + r.BlobHash + "\n" + r.ThreadID return sha256.Sum256([]byte(enc)) } // merkleRoot builds an RFC 6962-style tree: split at the largest power of two // strictly less than n, recurse, hash the pair under the node domain. That // shape keeps inclusion proofs (a later slice) standard. Must never be called // with zero leaves — a checkpoint only exists because journal rows do. func merkleRoot(leaves [][32]byte) [32]byte { if len(leaves) == 1 { return leaves[0] } k := 1 for k*2 < len(leaves) { k *= 2 } l, r := merkleRoot(leaves[:k]), merkleRoot(leaves[k:]) return sha256.Sum256(append(append([]byte(ckptNodeDomain), l[:]...), r[:]...)) } // merkleNode hashes an interior node (its two child hashes). Split out so the // audit-path recompute and merkleRoot agree byte for byte. func merkleNode(l, r [32]byte) [32]byte { return sha256.Sum256(append(append([]byte(ckptNodeDomain), l[:]...), r[:]...)) } // merkleAuditPath returns the RFC 6962 inclusion proof for leaf index m in a // tree of these leaves: the sibling subtree roots bottom-up. A verifier // recombines them with the leaf (rootFromAuditPath) to reach the committed // root — proving the leaf is in the tree without the server handing over the // whole log. Same power-of-two split as merkleRoot, so proofs are standard. func merkleAuditPath(leaves [][32]byte, m int) [][32]byte { n := len(leaves) if n <= 1 { return nil } k := 1 for k*2 < n { k *= 2 } if m < k { return append(merkleAuditPath(leaves[:k], m), merkleRoot(leaves[k:])) } return append(merkleAuditPath(leaves[k:], m-k), merkleRoot(leaves[:k])) } // rootFromAuditPath recomputes the Merkle root from a leaf, its index m in a // tree of n leaves, and its audit path — the verifier's side of // merkleAuditPath. It mirrors that function's recursion: the LAST path element // is the sibling at this level, and the rest belongs to the subtree the leaf // is in, so m/n alone decide each sibling's left/right position (no per-step // direction bit). The JS and Swift clients mirror this; a Go test pins all // three against each other for every (n, m). func rootFromAuditPath(leaf [32]byte, m, n int, path [][32]byte) ([32]byte, bool) { if m < 0 || m >= n { return [32]byte{}, false } if n == 1 { return leaf, len(path) == 0 } if len(path) == 0 { return [32]byte{}, false } k := 1 for k*2 < n { k *= 2 } sibling := path[len(path)-1] rest := path[:len(path)-1] if m < k { left, ok := rootFromAuditPath(leaf, m, k, rest) if !ok { return [32]byte{}, false } return merkleNode(left, sibling), true } right, ok := rootFromAuditPath(leaf, m-k, n-k, rest) if !ok { return [32]byte{}, false } return merkleNode(sibling, right), true } // chainHash commits a checkpoint to its predecessor. prevChain is "" for the // genesis checkpoint. seq_to rides inside so two chains can't agree while // disagreeing about how much history a head covers. func chainHash(prevChain, root string, seqTo int64) string { enc := ckptChainDomain + prevChain + "\n" + root + "\n" + strconv.FormatInt(seqTo, 10) sum := sha256.Sum256([]byte(enc)) return hex.EncodeToString(sum[:]) } // ckptPayload is what gets signed. Like releaseManifest, the signature covers // the base64 of this JSON — verify first, decode after. type ckptPayload struct { SeqFrom int64 `json:"seq_from"` SeqTo int64 `json:"seq_to"` Root string `json:"root"` // hex Merkle root over leaves [seq_from..seq_to] PrevChain string `json:"prev_chain"` // hex chain of the predecessor ("" = genesis) Chain string `json:"chain"` // hex chainHash(prev_chain, root, seq_to) TS int64 `json:"ts"` // unix seconds, sign time // The signing key's public half, so every checkpoint names its own key: a // verifier walking the chain sees a key CHANGE as an explicit event to // investigate, instead of old checkpoints suddenly failing against the // instance's current key. Pub string `json:"pub"` // base64 ed25519 public key } // signedCheckpoint mirrors release.go's signedManifest wire shape. type signedCheckpoint struct { Payload string `json:"payload"` // base64(JSON ckptPayload) Sig string `json:"sig"` // base64(ed25519 sig over the payload STRING) } func signCheckpoint(priv ed25519.PrivateKey, p ckptPayload) (string, error) { body, err := json.Marshal(p) if err != nil { return "", err } b64 := base64.StdEncoding.EncodeToString(body) wrapper, err := json.Marshal(signedCheckpoint{ Payload: b64, Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64))), }) return string(wrapper), err } // verifyCheckpoint checks the signature over the encoded payload string, then // decodes. It verifies attribution, not history — recomputing roots against // the journal and walking the chain is the verifier's job (see tests; clients // and monitors get this in later slices). func verifyCheckpoint(pub ed25519.PublicKey, wrapper string) (ckptPayload, error) { var sc signedCheckpoint if err := json.Unmarshal([]byte(wrapper), &sc); err != nil { return ckptPayload{}, err } sig, err := base64.StdEncoding.DecodeString(sc.Sig) if err != nil { return ckptPayload{}, err } if !ed25519.Verify(pub, []byte(sc.Payload), sig) { return ckptPayload{}, errors.New("bad checkpoint signature") } body, err := base64.StdEncoding.DecodeString(sc.Payload) if err != nil { return ckptPayload{}, err } var p ckptPayload if err := json.Unmarshal(body, &p); err != nil { return ckptPayload{}, err } if p.SeqFrom <= 0 || p.SeqTo < p.SeqFrom || p.Root == "" || p.Chain == "" { return ckptPayload{}, errors.New("malformed checkpoint payload") } // The payload must name the key that signed it — a payload claiming a // different key than the one whose signature just verified is confused or // hostile either way. if p.Pub != base64.StdEncoding.EncodeToString(pub) { return ckptPayload{}, errors.New("checkpoint payload names a different key") } return p, nil } // checkpointKey lazily loads (or mints) the per-instance signing key: from // LCHAT_CHECKPOINT_KEY_FILE when set, else from the DB config (the default — // the seed must ride hibernation wakes and VACUUM-INTO migrations, which move // only the DB). Only success is cached: a transient read failure must not // disable checkpointing for the process lifetime. func (s *Server) checkpointKey() (ed25519.PrivateKey, error) { s.ckptMu.Lock() defer s.ckptMu.Unlock() if s.ckptKey != nil { return s.ckptKey, nil } var priv ed25519.PrivateKey var err error if s.ckptKeyPath != "" { priv, err = loadOrCreateCheckpointKey(s.ckptKeyPath) } else { priv, err = s.db.ensureCheckpointSeed() } if err != nil { return nil, err } s.ckptKey = priv return priv, nil } // loadOrCreateCheckpointKey reads a base64 Ed25519 seed from path, minting one // on first use (temp-file + rename, so a crash mid-write can't leave a // truncated seed). Same storage shape as the release signing key (release.go). // A corrupt existing file is a loud error, never silently regenerated — a new // key mid-chain is exactly the event this feature exists to make visible. func loadOrCreateCheckpointKey(path string) (ed25519.PrivateKey, error) { b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { _, priv, gerr := ed25519.GenerateKey(rand.Reader) if gerr != nil { return nil, gerr } enc := base64.StdEncoding.EncodeToString(priv.Seed()) tmp := path + ".tmp" if werr := os.WriteFile(tmp, []byte(enc+"\n"), 0o600); werr != nil { return nil, fmt.Errorf("write checkpoint key: %w", werr) } if rerr := os.Rename(tmp, path); rerr != nil { return nil, fmt.Errorf("write checkpoint key: %w", rerr) } return priv, nil } if err != nil { return nil, err } return checkpointSeedKey(strings.TrimSpace(string(b)), path) } // checkpointSeedKey turns a stored base64 seed back into a key, shared by the // file and DB storage paths. func checkpointSeedKey(enc, what string) (ed25519.PrivateKey, error) { seed, err := base64.StdEncoding.DecodeString(enc) if err != nil { return nil, fmt.Errorf("checkpoint key %s: %w", what, err) } if len(seed) != ed25519.SeedSize { return nil, fmt.Errorf("checkpoint key %s: want %d-byte seed, got %d", what, ed25519.SeedSize, len(seed)) } return ed25519.NewKeyFromSeed(seed), nil } // runCheckpointer is the ticker; tests call sweepCheckpoint directly (the // runScheduler/sweepDueScheduled split, main.go). func (s *Server) runCheckpointer() { for range time.Tick(checkpointInterval) { s.sweepCheckpoint() } } // sweepCheckpoint folds new journal rows into checkpoints until the backlog is // drained, returning how many checkpoints it wrote. Zero new rows → zero work: // an idle instance's chain simply doesn't grow (no heartbeat checkpoints — // "nothing happened" is exactly what an unchanged head attests). The mutex // (plus the UNIQUE index on checkpoints.seq_from) keeps a second caller from // forking the chain — two checkpoints claiming the same predecessor would // read as tampering to a verifier. func (s *Server) sweepCheckpoint() int { s.ckptSweepMu.Lock() defer s.ckptSweepMu.Unlock() prev, havePrev, err := s.db.latestCheckpoint() if err != nil { log.Printf("checkpoint latest: %v", err) return 0 } after := int64(0) prevChain := "" if havePrev { after, prevChain = prev.SeqTo, prev.Chain } wrote := 0 for { rows, err := s.db.journalAfter(after, checkpointBatchSize) if err != nil { log.Printf("checkpoint journal: %v", err) return wrote } if len(rows) == 0 { return wrote } leaves := make([][32]byte, len(rows)) for i, r := range rows { leaves[i] = journalLeafHash(r) } root := merkleRoot(leaves) priv, err := s.checkpointKey() if err != nil { log.Printf("checkpoint key: %v", err) return wrote } p := ckptPayload{ SeqFrom: rows[0].Seq, SeqTo: rows[len(rows)-1].Seq, Root: hex.EncodeToString(root[:]), PrevChain: prevChain, TS: time.Now().Unix(), Pub: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)), } p.Chain = chainHash(p.PrevChain, p.Root, p.SeqTo) signed, err := signCheckpoint(priv, p) if err != nil { log.Printf("checkpoint sign: %v", err) return wrote } if err := s.db.insertCheckpoint(p, signed); err != nil { log.Printf("checkpoint store: %v", err) return wrote } wrote++ // Carry the new head forward instead of re-reading it — the loop must // not depend on observing its own insert through the DB. after, prevChain = p.SeqTo, p.Chain if len(rows) < checkpointBatchSize { return wrote } } } // getCheckpoints serves the chain, member-token-gated: the chain leaks // activity volume and timing, so it is for members and monitors (who hold // member credentials), not the public. `after` is a checkpoint row id — a // verifier keeps its last-seen head and pages forward from it. Each // checkpoint's payload embeds its own public key, so the chain stays // verifiable even when the current key is unavailable — key trouble degrades // public_key to "", it never withholds the evidence. func (s *Server) getCheckpoints(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "invalid invite") return } after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) cps, err := s.db.checkpointsAfter(after, 100) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } pub := "" if priv, err := s.checkpointKey(); err != nil { log.Printf("checkpoint key: %v", err) } else { pub = base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)) } writeJSON(w, http.StatusOK, map[string]any{ "checkpoints": cps, "public_key": pub, }) } // inclusionProof is one journal event proven to be in a committed checkpoint's // Merkle tree. The client recomputes the leaf from the row fields, recombines // the audit path to a root, and checks that root against the checkpoint it // already trusts via the signed chain — so it can confirm a message it holds // is committed to the tamper-evident log without the server handing over the // whole log. type inclusionProof struct { Seq int64 `json:"seq"` TS int64 `json:"ts"` Kind string `json:"kind"` MsgID int64 `json:"msg_id"` ThreadID string `json:"thread_id"` BlobHash string `json:"blob_hash"` CheckpointID int64 `json:"checkpoint_id"` Root string `json:"root"` LeafIndex int `json:"leaf_index"` TreeSize int `json:"tree_size"` AuditPath []string `json:"audit_path"` // hex sibling hashes, bottom-up } // getCheckpointProof serves Merkle inclusion proofs for a message: every // journal event of that message id (insert / edit / delete) that a checkpoint // already covers, each with its audit path. A client sampling the message // matches the proof whose blob_hash equals its held ciphertext's hash and // verifies it against a trusted root. `committed_to` is the highest checkpointed // seq, so a client can tell "not proven because too recent" (seq beyond it) // from "not proven because missing" (tampering). Member-gated like messages. func (s *Server) getCheckpointProof(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "invalid invite") return } msgID, err := strconv.ParseInt(r.URL.Query().Get("msg"), 10, 64) if err != nil || msgID <= 0 { httpError(w, http.StatusBadRequest, "bad msg id") return } journalRows, err := s.db.journalRowsForMsg(msgID) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } head, hasHead, err := s.db.latestCheckpoint() if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } committedTo := int64(0) if hasHead { committedTo = head.SeqTo } proofs := []inclusionProof{} for _, jr := range journalRows { cp, covered, err := s.db.checkpointCovering(jr.Seq) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if !covered { continue // too recent to be checkpointed yet — not an error } proof, ok, err := s.buildInclusionProof(jr, cp) if err != nil { httpError(w, http.StatusInternalServerError, "db error") return } if ok { proofs = append(proofs, proof) } } writeJSON(w, http.StatusOK, map[string]any{ "proofs": proofs, "committed_to": committedTo, }) } // buildInclusionProof rebuilds the covering checkpoint's Merkle tree from the // journal and extracts the audit path for jr's leaf. ok=false only if the // journal row somehow isn't in its own checkpoint's range (shouldn't happen — // the journal is append-only — but never serve a bogus proof). func (s *Server) buildInclusionProof(jr JournalRow, cp Checkpoint) (inclusionProof, bool, error) { rows, err := s.db.journalRange(cp.SeqFrom, cp.SeqTo) if err != nil { return inclusionProof{}, false, err } idx := -1 leaves := make([][32]byte, len(rows)) for i, row := range rows { leaves[i] = journalLeafHash(row) if row.Seq == jr.Seq { idx = i } } if idx < 0 { return inclusionProof{}, false, nil } path := merkleAuditPath(leaves, idx) hexPath := make([]string, len(path)) for i, h := range path { hexPath[i] = hex.EncodeToString(h[:]) } return inclusionProof{ Seq: jr.Seq, TS: jr.TS, Kind: jr.Kind, MsgID: jr.MsgID, ThreadID: jr.ThreadID, BlobHash: jr.BlobHash, CheckpointID: cp.ID, Root: cp.Root, LeafIndex: idx, TreeSize: len(leaves), AuditPath: hexPath, }, true, nil }