eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Checkpoint.swift — native tamper-evidence: verify the instance's signed
2// checkpoint chain and gossip verified heads between members inside E2EE
3// traffic (docs/tamper-evidence.md, #573 slices 1+2). The Swift twin of
4// web/static/integrity.js — the pure verification core is nonisolated static
5// so it can be unit-tested with `swift test` and pinned to the SAME wire
6// vectors the Go and web sides pin (CheckpointTests + checkpoint_test.go +
7// integrity.test.mjs must agree, or the platforms disagree about what a valid
8// chain is).
9//
10// Ed25519 signature verification uses CryptoKit's Curve25519.Signing, over the
11// base64 payload STRING bytes exactly as the web does (verify-first, decode
12// after). The chain of hashes is what holds history together; the signature is
13// attribution. A verification failure must always END as a recorded alert,
14// never a thrown error — a monitor that dies quietly on one garbled row is an
15// alarm-suppression vector.
16
17import Foundation
18import CryptoKit
19
20// MARK: - Wire types (match /api/checkpoints and checkpoint.go)
21
22/// One checkpoint row as served by GET /api/checkpoints.
23public struct CheckpointRow: Codable, Sendable, Equatable {
24 public let id: Int64
25 public let seq_from: Int64
26 public let seq_to: Int64
27 public let root: String
28 public let chain: String
29 public let ts: Int64
30 public let signed: String // JSON: {payload: base64(JSON CkptPayload), sig: base64}
31}
32
33public struct CheckpointsResponse: Codable, Sendable {
34 public let checkpoints: [CheckpointRow]?
35 public let public_key: String?
36}
37
38/// The signed wrapper inside `CheckpointRow.signed` (release.go shape).
39private struct SignedWrapper: Codable { let payload: String; let sig: String }
40
41/// The decoded payload the signature covers.
42private struct CkptPayload: Codable {
43 let seq_from: Int64
44 let seq_to: Int64
45 let root: String
46 let prev_chain: String
47 let chain: String
48 let ts: Int64
49 let pub: String // base64 ed25519 public key that signed this checkpoint
50}
51
52/// A member's gossiped head: `[seqTo, chain]`, encoded as a heterogeneous JSON
53/// array to match the web's `_ckpt` exactly. Rides inside the encrypted message
54/// payload so the server can neither read nor selectively strip it.
55public struct CkptGossip: Codable, Sendable, Equatable {
56 public let seqTo: Int64
57 public let chain: String
58 public init(seqTo: Int64, chain: String) { self.seqTo = seqTo; self.chain = chain }
59 public init(from decoder: Decoder) throws {
60 var c = try decoder.unkeyedContainer()
61 seqTo = try c.decode(Int64.self)
62 chain = try c.decode(String.self)
63 }
64 public func encode(to encoder: Encoder) throws {
65 var c = encoder.unkeyedContainer()
66 try c.encode(seqTo)
67 try c.encode(chain)
68 }
69}
70
71/// The verified head of the chain this device has confirmed.
72public struct CkptHead: Codable, Sendable, Equatable {
73 public var id: Int64
74 public var seqTo: Int64
75 public var chain: String
76 public var pub: String
77 public var gossip: CkptGossip { CkptGossip(seqTo: seqTo, chain: chain) }
78}
79
80/// An integrity alert. `kind` strings match the web exactly.
81public struct CkptAlert: Codable, Sendable, Equatable {
82 public var kind: String
83 public var seqTo: Int64?
84 public var id: Int64?
85 public var at: Int64
86 /// Investigable but not fatal (a key change) — the walk continues past it.
87 public var keep: Bool?
88 var key: String { "\(kind):\(seqTo.map(String.init) ?? id.map(String.init) ?? "")" }
89}
90
91/// The result of verifying one served page of checkpoints.
92public struct CkptPageResult: Sendable {
93 public var head: CkptHead?
94 public var hist: [CkptGossip] // verified (seqTo, chain) boundary pairs
95 public var roots: [String] // verified checkpoint roots (inclusion-proof trust)
96 public var alerts: [CkptAlert]
97 public var fatal: Bool // a non-`keep` alert stopped the walk
98}
99
100/// One journal event proven to be in a committed checkpoint's Merkle tree
101/// (docs/tamper-evidence.md client sampling). The wire shape from
102/// GET /api/checkpoint-proof (checkpoint.go inclusionProof). The client
103/// recomputes the leaf from the row fields, walks the audit path, and checks
104/// the root against a checkpoint it already trusts via the signed chain.
105public struct InclusionProof: Codable, Sendable {
106 public let seq: Int64
107 public let ts: Int64
108 public let kind: String
109 public let msg_id: Int64
110 public let thread_id: String
111 public let blob_hash: String
112 public let checkpoint_id: Int64
113 public let root: String
114 public let leaf_index: Int
115 public let tree_size: Int
116 public let audit_path: [String] // hex sibling hashes, bottom-up
117}
118
119public struct CheckpointProofResponse: Codable, Sendable {
120 public let proofs: [InclusionProof]?
121 public let committed_to: Int64?
122}
123
124// MARK: - Pure verification core (nonisolated, vector-pinned)
125
126public enum Checkpoint {
127 static let chainDomain = "lchat-ckpt-chain\n"
128
129 /// chainHashHex mirrors checkpoint.go's chainHash and integrity.js's
130 /// chainHashHex — a checkpoint's commitment to its predecessor. `prev` is
131 /// "" for the genesis checkpoint. This is wire format: it MUST match the Go
132 /// and web sides byte for byte (pinned by CheckpointTests).
133 public static func chainHashHex(prev: String, root: String, seqTo: Int64) -> String {
134 let s = chainDomain + prev + "\n" + root + "\n" + String(seqTo)
135 let digest = SHA256.hash(data: Data(s.utf8))
136 return digest.map { String(format: "%02x", $0) }.joined()
137 }
138
139 /// ed25519Verify checks one signature over the base64 payload STRING bytes,
140 /// returning false (never throwing) on any malformed input — a garbled row
141 /// is a recorded failure, not a crash.
142 public static func ed25519Verify(pubBase64: String, payload: String, sigBase64: String) -> Bool {
143 guard let pubData = Data(base64Encoded: pubBase64),
144 let sigData = Data(base64Encoded: sigBase64),
145 let key = try? Curve25519.Signing.PublicKey(rawRepresentation: pubData)
146 else { return false }
147 return key.isValidSignature(sigData, for: Data(payload.utf8))
148 }
149
150 /// verifyPage walks one page of served checkpoints, extending `prior` (the
151 /// last verified head, or nil on first sync). Each payload names its own
152 /// signing key, so a mid-chain key change alerts (investigable) but the
153 /// walk continues; broken hashes/links/signatures stop it. A faithful port
154 /// of integrity.js verifyCheckpointPage.
155 public static func verifyPage(_ rows: [CheckpointRow], prior: CkptHead?, checkSigs: Bool) -> CkptPageResult {
156 var alerts: [CkptAlert] = []
157 var hist: [CkptGossip] = []
158 var roots: [String] = [] // root is chain-hash-bound below, so trusting it is sound
159 var head = prior
160 var pub = prior?.pub
161 let now = Int64(Date().timeIntervalSince1970)
162
163 for cp in rows {
164 // Parse: signed wrapper → base64 payload → CkptPayload. Any failure
165 // is a "malformed" alert, never a throw.
166 guard let signedData = cp.signed.data(using: .utf8),
167 let wrapper = try? JSONDecoder().decode(SignedWrapper.self, from: signedData),
168 let payloadData = Data(base64Encoded: wrapper.payload),
169 let payload = try? JSONDecoder().decode(CkptPayload.self, from: payloadData),
170 !payload.pub.isEmpty
171 else {
172 alerts.append(CkptAlert(kind: "malformed", seqTo: nil, id: cp.id, at: now))
173 break
174 }
175 if checkSigs && !ed25519Verify(pubBase64: payload.pub, payload: wrapper.payload, sigBase64: wrapper.sig) {
176 alerts.append(CkptAlert(kind: "bad-signature", seqTo: nil, id: cp.id, at: now))
177 break
178 }
179 if chainHashHex(prev: payload.prev_chain, root: payload.root, seqTo: payload.seq_to) != payload.chain {
180 alerts.append(CkptAlert(kind: "bad-chain-hash", seqTo: nil, id: cp.id, at: now))
181 break
182 }
183 if payload.prev_chain != (head?.chain ?? "") {
184 alerts.append(CkptAlert(kind: "broken-link", seqTo: payload.seq_to, id: cp.id, at: now))
185 break
186 }
187 if let h = head, payload.seq_from != h.seqTo + 1 {
188 alerts.append(CkptAlert(kind: "gap", seqTo: payload.seq_to, id: cp.id, at: now))
189 break
190 }
191 if cp.seq_to != payload.seq_to || cp.chain != payload.chain {
192 alerts.append(CkptAlert(kind: "row-drift", seqTo: nil, id: cp.id, at: now))
193 break
194 }
195 if let p = pub, payload.pub != p {
196 // Legitimate-but-investigable (restore from a pre-key backup,
197 // deliberate rotation) — alert once, keep verifying.
198 alerts.append(CkptAlert(kind: "key-change", seqTo: payload.seq_to, id: cp.id, at: now, keep: true))
199 }
200 pub = payload.pub
201 head = CkptHead(id: cp.id, seqTo: payload.seq_to, chain: payload.chain, pub: payload.pub)
202 hist.append(CkptGossip(seqTo: payload.seq_to, chain: payload.chain))
203 roots.append(payload.root)
204 }
205 let fatal = alerts.contains { $0.keep != true }
206 return CkptPageResult(head: head, hist: hist, roots: roots, alerts: alerts, fatal: fatal)
207 }
208
209 // MARK: - Inclusion proofs (mirror checkpoint.go / integrity.js)
210
211 static let leafDomain = "lchat-ckpt-leaf\n"
212 static let nodeDomain = "lchat-ckpt-node\n"
213
214 private static func hex(_ d: [UInt8]) -> String { d.map { String(format: "%02x", $0) }.joined() }
215 private static func fromHex(_ s: String) -> [UInt8]? {
216 guard s.count % 2 == 0 else { return nil }
217 var out = [UInt8](); out.reserveCapacity(s.count / 2)
218 var i = s.startIndex
219 while i < s.endIndex {
220 let j = s.index(i, offsetBy: 2)
221 guard let b = UInt8(s[i..<j], radix: 16) else { return nil }
222 out.append(b); i = j
223 }
224 return out
225 }
226
227 /// leafHashHex mirrors journalLeafHash — the domain-separated hash of a
228 /// journal row (the tree's leaf). Field order is wire format (checkpoint.go).
229 public static func leafHashHex(seq: Int64, ts: Int64, kind: String, msgID: Int64, blobHash: String, threadID: String) -> String {
230 let enc = leafDomain + String(seq) + "\n" + String(ts) + "\n" + kind + "\n"
231 + String(msgID) + "\n" + blobHash + "\n" + threadID
232 return hex(Array(SHA256.hash(data: Data(enc.utf8))))
233 }
234
235 private static func nodeHash(_ l: [UInt8], _ r: [UInt8]) -> [UInt8] {
236 var data = Data(nodeDomain.utf8); data.append(contentsOf: l); data.append(contentsOf: r)
237 return Array(SHA256.hash(data: data))
238 }
239
240 /// rootFromAuditPath recomputes the Merkle root from a leaf (hex), its index
241 /// m in a tree of n leaves, and its audit path (hex siblings, bottom-up) —
242 /// the exact mirror of checkpoint.go's rootFromAuditPath. Returns the root
243 /// hex, or nil on a malformed proof.
244 public static func rootFromAuditPath(leafHex: String, m: Int, n: Int, pathHex: [String]) -> String? {
245 guard m >= 0, m < n, let leaf = fromHex(leafHex) else { return nil }
246 var path: [[UInt8]] = []
247 for h in pathHex { guard let b = fromHex(h) else { return nil }; path.append(b) }
248 func recompute(_ leaf: [UInt8], _ m: Int, _ n: Int, _ path: [[UInt8]]) -> [UInt8]? {
249 if n == 1 { return path.isEmpty ? leaf : nil }
250 if path.isEmpty { return nil }
251 var k = 1
252 while k * 2 < n { k *= 2 }
253 let sibling = path[path.count - 1]
254 let rest = Array(path[0..<(path.count - 1)])
255 if m < k {
256 guard let left = recompute(leaf, m, k, rest) else { return nil }
257 return nodeHash(left, sibling)
258 }
259 guard let right = recompute(leaf, m - k, n - k, rest) else { return nil }
260 return nodeHash(sibling, right)
261 }
262 guard let root = recompute(leaf, m, n, path) else { return nil }
263 return hex(root)
264 }
265
266 /// verifyInclusion checks one proof: recompute the leaf from its row fields,
267 /// walk the audit path, and confirm it reaches the proof's stated root. The
268 /// caller separately checks proof.root is a root it TRUSTS (via the signed
269 /// chain); this only proves the leaf is in that root's tree. Returns
270 /// (ok, recomputed root).
271 public static func verifyInclusion(_ p: InclusionProof) -> (ok: Bool, root: String?) {
272 let leaf = leafHashHex(seq: p.seq, ts: p.ts, kind: p.kind, msgID: p.msg_id, blobHash: p.blob_hash, threadID: p.thread_id)
273 let root = rootFromAuditPath(leafHex: leaf, m: p.leaf_index, n: p.tree_size, pathHex: p.audit_path)
274 return (root != nil && root == p.root, root)
275 }
276
277 /// blobHashHex commits to a stored ciphertext string the way the server does
278 /// (ciphertextHash = sha256 hex of the blob string), so a client sampling a
279 /// message can match the journal's blob_hash against what it holds.
280 public static func blobHashHex(_ blob: String) -> String {
281 hex(Array(SHA256.hash(data: Data(blob.utf8))))
282 }
283
284 /// gossipVerdict compares a peer's gossiped head against our verified head +
285 /// bounded boundary history. Checkpoint boundaries are global (one
286 /// checkpoints table per instance), so a peer boundary inside our verified
287 /// window that we don't have at all is a fork. Returns: "match" | "fork" |
288 /// "ahead" | "unknown-old" | "no-head". Port of integrity.js gossipVerdict.
289 public static func gossipVerdict(head: CkptHead?, hist: [CkptGossip], gossip: CkptGossip) -> String {
290 guard let head else { return "no-head" }
291 let s = gossip.seqTo
292 if s > head.seqTo { return "ahead" }
293 if s == head.seqTo { return gossip.chain == head.chain ? "match" : "fork" }
294 let floor = hist.first?.seqTo ?? head.seqTo
295 if s < floor { return "unknown-old" }
296 guard let ours = hist.first(where: { $0.seqTo == s }) else { return "fork" }
297 return ours.chain == gossip.chain ? "match" : "fork"
298 }
299}
300