eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// IntegrityMonitor.swift — the stateful half of native tamper-evidence, the
2// twin of integrity.js's createIntegrity. Polls GET /api/checkpoints, verifies
3// each new checkpoint, remembers the verified head, and compares gossiped peer
4// heads to detect forks/rollback/withholding. @MainActor + single-process, so
5// (unlike the web's multi-tab localStorage) the monitor never races ITSELF —
6// the feed and the poll timer are the same actor. It does NOT follow that its
7// UserDefaults persistence is race-free: UserDefaults.standard is a
8// process-global store shared with other writers (e.g. ThreadStore's read
9// watermark), so every writer of it must stay on the main actor or a
10// background write will corrupt CFPrefs mid-encode. Two rules keep that true
11// here: this class is @MainActor (so save() runs on main), and folding a whole
12// page of history coalesces into ONE save via withCoalescedSave (below) rather
13// than a per-message burst of main-thread writes.
14//
15// Detection only in this slice: alerts are recorded and published; how they
16// surface (the failure banner, /integrity) is the UI layer. `onAlert` fires
17// once per genuinely-new alert so a persistent condition doesn't re-nag.
18
19import Foundation
20
21/// Where the monitor persists its state. UserDefaults-backed in the app, an
22/// in-memory fake in tests. The verified head is not secret — it's a
23/// commitment the server already published — so plain per-instance storage is
24/// fine (no Keychain needed).
25public protocol IntegrityStore: AnyObject {
26 func loadIntegrity() -> Data?
27 func saveIntegrity(_ data: Data)
28}
29
30/// UserDefaults-backed store, one key per instance host.
31public final class UserDefaultsIntegrityStore: IntegrityStore {
32 private let defaults: UserDefaults
33 private let key: String
34 public init(host: String, defaults: UserDefaults = .standard) {
35 self.defaults = defaults
36 self.key = "lchat_ckpt_" + host
37 }
38 public func loadIntegrity() -> Data? { defaults.data(forKey: key) }
39 public func saveIntegrity(_ data: Data) { defaults.set(data, forKey: key) }
40}
41
42/// Persisted monitor state (mirrors integrity.js's lchat_ckpt blob).
43struct IntegrityState: Codable {
44 var head: CkptHead?
45 var hist: [CkptGossip] = []
46 var roots: [String] = [] // verified checkpoint roots (inclusion-proof trust)
47 var alerts: [CkptAlert] = []
48 var acked: [String] = []
49 var pending: PendingAhead?
50 var sigOk: Bool?
51 var pub: String?
52 var lastPollAt: Int64?
53 var gossipSeen: Int = 0
54 var gossipMatch: Int = 0
55 var sampleVerified: Int = 0 // held messages proven committed via inclusion proofs
56
57 struct PendingAhead: Codable { var seqTo: Int64; var chain: String; var at: Int64 }
58}
59
60@MainActor
61public final class IntegrityMonitor: ObservableObject {
62 /// How long a gossiped head may stay ahead of everything the server shows
63 /// us before we call it withholding (the sweep runs every 5 minutes).
64 public static let aheadGraceMs: Int64 = 15 * 60 * 1000
65 private static let histKeep = 64
66 private static let rootsKeep = 64
67 private static let alertsKeep = 20
68 private static let pageSize = 100
69
70 private let store: IntegrityStore
71 private let now: () -> Int64
72 /// Fires once per genuinely-new alert (raises the failure banner).
73 public var onAlert: ((CkptAlert) -> Void)?
74
75 /// Published so SwiftUI (the banner) reacts to failures without polling.
76 @Published public private(set) var unresolved: [CkptAlert] = []
77
78 private var state: IntegrityState
79
80 // Coalesced-save scope (see withCoalescedSave): while deferring, save()
81 // just flags the state dirty and returns; the flush happens once when the
82 // outermost scope exits.
83 private var saveDeferred = false
84 private var saveDirty = false
85
86 public init(store: IntegrityStore, now: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }) {
87 self.store = store
88 self.now = now
89 if let data = store.loadIntegrity(), let s = try? JSONDecoder().decode(IntegrityState.self, from: data) {
90 state = s
91 } else {
92 state = IntegrityState()
93 }
94 recomputeUnresolved()
95 }
96
97 // MARK: emit / receive gossip
98
99 /// The `_ckpt` value for an outgoing payload (nil before the first verified
100 /// head). Injected inside the encrypted message so the server can't strip it.
101 public func gossipOut() -> CkptGossip? { state.head?.gossip }
102
103 /// Fold a peer's `_ckpt` from a decrypted incoming payload — live AND
104 /// history (the server chooses which path a message arrives by, so both
105 /// must count). `countPeer` guards the stat: own echoes and history
106 /// refolds compare (evidence is evidence) but don't inflate the peer count.
107 @discardableResult
108 public func gossipIn(_ gossip: CkptGossip, countPeer: Bool = true) -> String {
109 let v = Checkpoint.gossipVerdict(head: state.head, hist: state.hist, gossip: gossip)
110 if countPeer && (v == "match" || v == "fork" || v == "ahead") {
111 state.gossipSeen += 1
112 if v == "match" { state.gossipMatch += 1 }
113 }
114 if v == "fork" { addAlert(CkptAlert(kind: "gossip-fork", seqTo: gossip.seqTo, id: nil, at: now())) }
115 if v == "ahead" {
116 if state.pending == nil || gossip.seqTo > state.pending!.seqTo {
117 let at = state.pending?.at ?? now()
118 state.pending = .init(seqTo: gossip.seqTo, chain: gossip.chain, at: at)
119 }
120 }
121 save()
122 return v
123 }
124
125 /// Run `body` with the per-call UserDefaults writes coalesced into a single
126 /// save at the end. Folding a page of history calls gossipIn once per
127 /// decrypted message; without this each call would re-encode and rewrite the
128 /// whole state blob to UserDefaults on the main thread — a burst that both
129 /// wastes work and widens the window for a cross-thread CFPrefs race. Alert
130 /// publishing (recomputeUnresolved / onAlert) still fires per call, so the
131 /// banner reacts immediately; only the disk write is deferred. Re-entrant.
132 public func withCoalescedSave(_ body: () -> Void) {
133 let wasDeferred = saveDeferred
134 saveDeferred = true
135 body()
136 saveDeferred = wasDeferred
137 if !saveDeferred && saveDirty {
138 saveDirty = false
139 save()
140 }
141 }
142
143 /// recordAlert lets other subsystems (e.g. key-transparency bind-MAC
144 /// verification) file an integrity alert through the same deduped,
145 /// banner-raising path the chain checks use. `key` distinguishes instances
146 /// of the same kind (e.g. a user id) so re-checking the same member doesn't
147 /// spam. Mirrors integrity.js recordAlert.
148 public func recordAlert(kind: String, key: Int64) {
149 addAlert(CkptAlert(kind: kind, seqTo: nil, id: key, at: now()))
150 save()
151 }
152
153 // MARK: inclusion-proof sampling
154
155 /// sampleAndVerify proves a handful of messages the client HOLDS are
156 /// committed to the chain (docs/tamper-evidence.md client sampling). For
157 /// each (msgID, blobHash) sample, fetch its inclusion proofs and check them
158 /// against roots we verified via the signed chain. Sound + false-alarm-free:
159 /// • a proof against a trusted root whose audit path doesn't recompute →
160 /// the server forged it → "bad-proof" alert.
161 /// • a proof against a trusted root matching our held ciphertext → that
162 /// message is provably committed (counted).
163 /// • no matching proof → NOT alarmed (could be too recent to checkpoint).
164 /// Mirror of integrity.js sampleAndVerify.
165 @discardableResult
166 public func sampleAndVerify(_ samples: [(msgID: Int64, blobHash: String)],
167 fetchProof: (Int64) async -> CheckpointProofResponse?) async -> (checked: Int, verified: Int) {
168 let trusted = Set(state.roots)
169 guard !trusted.isEmpty else { return (0, 0) } // nothing verified to check against yet
170 var checked = 0, verified = 0
171 for s in samples {
172 guard let resp = await fetchProof(s.msgID), let proofs = resp.proofs else { continue }
173 checked += 1
174 for proof in proofs {
175 guard trusted.contains(proof.root) else { continue } // a checkpoint we haven't verified
176 let (ok, _) = Checkpoint.verifyInclusion(proof)
177 if !ok {
178 addAlert(CkptAlert(kind: "bad-proof", seqTo: proof.seq, id: nil, at: now()))
179 continue
180 }
181 if proof.blob_hash == s.blobHash { verified += 1 }
182 }
183 }
184 state.sampleVerified += verified
185 save()
186 return (checked, verified)
187 }
188
189 // MARK: poll
190
191 /// Verify everything past our head. The first page re-fetches the head row
192 /// itself (after = head.id - 1) so a served chain that lost our verified
193 /// head — a rollback — is caught, not mistaken for silence. Network
194 /// failures are silent (next poll retries); verification failures alert and
195 /// stop; malformed rows alert, never throw. `fetchPage` returns the parsed
196 /// /api/checkpoints body (nil on a network error).
197 public func poll(_ fetchPage: (Int64) async -> CheckpointsResponse?) async {
198 if state.sigOk == nil { state.sigOk = true } // CryptoKit always has Ed25519
199 var first = true
200 var caughtUp = false
201 var pages = 0
202 while pages < 50 {
203 pages += 1
204 let after: Int64 = state.head.map { $0.id - (first ? 1 : 0) } ?? 0
205 guard let resp = await fetchPage(after), let rowsAll = resp.checkpoints else {
206 save(); return // offline / bad response — nothing to conclude
207 }
208 let rawLen = rowsAll.count
209 var rows = rowsAll
210 if let head = state.head, first {
211 guard let mine = rows.first(where: { $0.id == head.id }),
212 mine.seq_to == head.seqTo, mine.chain == head.chain else {
213 addAlert(CkptAlert(kind: "rollback", seqTo: head.seqTo, id: head.id, at: now()))
214 save(); return
215 }
216 rows = rows.filter { $0.id > head.id }
217 }
218 first = false
219 if let pk = resp.public_key, !pk.isEmpty {
220 if let have = state.pub, have != pk {
221 addAlert(CkptAlert(kind: "key-change", seqTo: nil, id: 0, at: now()))
222 save(); return
223 }
224 if state.pub == nil { state.pub = pk }
225 }
226 if !rows.isEmpty {
227 let out = Checkpoint.verifyPage(rows, prior: state.head, checkSigs: state.sigOk ?? true)
228 for a in out.alerts { addAlert(a) }
229 if let h = out.head { state.head = h }
230 state.hist = Array((state.hist + out.hist).suffix(Self.histKeep))
231 state.roots = Array((state.roots + out.roots).suffix(Self.rootsKeep))
232 if out.fatal { save(); return }
233 }
234 if rawLen < Self.pageSize { caughtUp = true; break }
235 }
236 // Resolve a pending "ahead" gossip: once our verified head reaches its
237 // seq, matching boundary → clean, mismatched/missing → fork. Past the
238 // grace window unresolved (and caught up) → the server is withholding.
239 if let p = state.pending, let head = state.head, head.seqTo >= p.seqTo {
240 if Checkpoint.gossipVerdict(head: head, hist: state.hist, gossip: CkptGossip(seqTo: p.seqTo, chain: p.chain)) == "fork" {
241 addAlert(CkptAlert(kind: "gossip-fork", seqTo: p.seqTo, id: nil, at: now()))
242 }
243 state.pending = nil
244 } else if let p = state.pending, caughtUp, now() - p.at > Self.aheadGraceMs {
245 addAlert(CkptAlert(kind: "withheld", seqTo: p.seqTo, id: nil, at: now()))
246 state.pending = nil
247 }
248 state.lastPollAt = now()
249 save()
250 }
251
252 // MARK: banner / report accessors
253
254 /// Alerts the member hasn't dismissed — what the failure banner shows.
255 public func unresolvedAlerts() -> [CkptAlert] {
256 state.alerts.filter { a in !state.acked.contains(a.key) }
257 }
258
259 /// Dismiss every current alert from the banner. A later, DIFFERENT alert
260 /// (new kind or seq) re-raises; the record itself is kept for /integrity.
261 public func acknowledge() {
262 state.acked = Array(Set(state.acked + state.alerts.map { $0.key })).suffix(Self.alertsKeep * 2).map { $0 }
263 save()
264 }
265
266 /// Read-only snapshot for the /integrity report.
267 public struct Report: Sendable {
268 public var head: CkptHead?
269 public var lastPollAt: Int64?
270 public var sigOk: Bool
271 public var gossipSeen: Int
272 public var gossipMatch: Int
273 public var sampleVerified: Int
274 public var alertCount: Int
275 public var latestAlertKind: String?
276 }
277 public func report() -> Report {
278 Report(head: state.head, lastPollAt: state.lastPollAt, sigOk: state.sigOk ?? true,
279 gossipSeen: state.gossipSeen, gossipMatch: state.gossipMatch,
280 sampleVerified: state.sampleVerified,
281 alertCount: state.alerts.count, latestAlertKind: state.alerts.last?.kind)
282 }
283
284 // MARK: shared copy (identical on iOS + Mac)
285
286 /// Plain-language line for an alert kind — members, not developers. Every
287 /// kind is a server misbehaviour the E2E rule is meant to make un-hideable.
288 /// Mirrors the web's integrityAlertMessage.
289 nonisolated public static func alertMessage(_ kind: String) -> String {
290 switch kind {
291 case "gossip-fork":
292 return "Your record of this space's history disagrees with another member's. A trustworthy server shows everyone the same history — this one may not have."
293 case "rollback":
294 return "This space's server served an older version of its history than your device had already verified."
295 case "withheld":
296 return "Another member has verified activity in this space that this space's server won't show you."
297 case "bad-signature", "malformed":
298 return "This space's activity record failed its cryptographic check — it may have been altered."
299 case "broken-link", "bad-chain-hash", "gap", "row-drift", "key-change":
300 return "This space's activity record doesn't hold together as it should — a sign it may have been altered."
301 case "bad-proof":
302 return "The server couldn't prove one of your messages is in this space's activity record — a sign the record may have been altered."
303 case "key-substitution":
304 return "The key shown for someone you invited isn't the one they set up with. The server may have swapped in its own key to read your messages — don't share anything sensitive, and check with them on another channel."
305 default:
306 return "This space failed an integrity check."
307 }
308 }
309
310 /// The /integrity read-out, one string per line. Pluralised + localised
311 /// counts (CLAUDE.md), matching the web command's private reply.
312 public func reportLines() -> [String] {
313 let r = report()
314 var lines: [String] = []
315 if let head = r.head {
316 lines.append("Verified up to entry \(head.seqTo.formatted()), chain \(head.chain.prefix(12))…")
317 if let ms = r.lastPollAt {
318 let when = Date(timeIntervalSince1970: Double(ms) / 1000)
319 .formatted(date: .abbreviated, time: .shortened)
320 lines.append("Last checked: \(when)")
321 }
322 lines.append(r.sigOk
323 ? "Signatures: verified (Ed25519)"
324 : "Signatures: not checked on this device; the hash chain is still verified")
325 if r.gossipSeen > 0 {
326 let heads = r.gossipSeen == 1 ? "1 peer's record" : "\(r.gossipSeen.formatted()) peers' records"
327 lines.append("Cross-checked against \(heads) (\(r.gossipMatch.formatted()) matched)")
328 }
329 if r.sampleVerified > 0 {
330 lines.append("Your messages proven in the record: \(r.sampleVerified.formatted())")
331 }
332 } else {
333 lines.append("No verified checkpoints yet — the chain starts with this space's first activity.")
334 }
335 let unresolved = unresolvedAlerts()
336 if unresolved.isEmpty {
337 lines.append("No integrity alerts.")
338 } else {
339 let n = unresolved.count
340 let noun = n == 1 ? "1 integrity alert" : "\(n.formatted()) integrity alerts"
341 lines.append("⚠️ \(noun) — latest: \(unresolved.last!.kind). Keep this device — its records are the evidence — and contact your space's admin.")
342 }
343 return lines
344 }
345
346 // MARK: internals
347
348 private func addAlert(_ a: CkptAlert) {
349 guard !state.alerts.contains(where: { $0.kind == a.kind && $0.seqTo == a.seqTo && $0.id == a.id }) else { return }
350 state.alerts = Array(state.alerts.suffix(Self.alertsKeep - 1)) + [a]
351 onAlert?(a)
352 }
353
354 private func save() {
355 recomputeUnresolved()
356 // Inside a withCoalescedSave scope, defer the actual disk write to the
357 // single flush at scope exit (see withCoalescedSave).
358 if saveDeferred { saveDirty = true; return }
359 if let data = try? JSONEncoder().encode(state) { store.saveIntegrity(data) }
360 }
361
362 private func recomputeUnresolved() {
363 unresolved = state.alerts.filter { a in !state.acked.contains(a.key) }
364 }
365}
366