| 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 | |
| 19 | import 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). |
| 25 | public protocol IntegrityStore: AnyObject { |
| 26 | func loadIntegrity() -> Data? |
| 27 | func saveIntegrity(_ data: Data) |
| 28 | } |
| 29 | |
| 30 | /// UserDefaults-backed store, one key per instance host. |
| 31 | public 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). |
| 43 | struct 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 |
| 61 | public 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 | |