| 1 | import XCTest |
| 2 | import CryptoKit |
| 3 | @testable import LChatCore |
| 4 | |
| 5 | // Native half of the tamper-evidence wire format (#573 slices 1+2). The |
| 6 | // chain-hash vector here is the SAME frozen value checkpoint_test.go |
| 7 | // (TestCheckpointHashVectors) and integrity.test.mjs pin: if any of the three |
| 8 | // drifts, the platforms disagree about what a valid chain is. Fix the code, |
| 9 | // not the vector. |
| 10 | final class CheckpointTests: XCTestCase { |
| 11 | |
| 12 | // MARK: test key + checkpoint builder |
| 13 | |
| 14 | private let key = Curve25519.Signing.PrivateKey() |
| 15 | private var pubB64: String { key.publicKey.rawRepresentation.base64EncodedString() } |
| 16 | |
| 17 | private struct BuiltRow { let row: CheckpointRow; let chain: String } |
| 18 | |
| 19 | private func mkCheckpoint(id: Int64, seqFrom: Int64, seqTo: Int64, prevChain: String, |
| 20 | signKey: Curve25519.Signing.PrivateKey? = nil, pub: String? = nil, |
| 21 | badSig: String? = nil, rowChain: String? = nil) -> BuiltRow { |
| 22 | let root = String(String(repeating: String(seqTo), count: 4).prefix(64)).padding(toLength: 64, withPad: "a", startingAt: 0) |
| 23 | let chain = Checkpoint.chainHashHex(prev: prevChain, root: root, seqTo: seqTo) |
| 24 | let payload: [String: Any] = [ |
| 25 | "seq_from": seqFrom, "seq_to": seqTo, "root": root, |
| 26 | "prev_chain": prevChain, "chain": chain, "ts": 1_700_000_000, "pub": pub ?? pubB64, |
| 27 | ] |
| 28 | let payloadData = try! JSONSerialization.data(withJSONObject: payload) |
| 29 | let payloadB64 = payloadData.base64EncodedString() |
| 30 | let sig = try! badSig ?? (signKey ?? key).signature(for: Data(payloadB64.utf8)).base64EncodedString() |
| 31 | let wrapper = try! JSONSerialization.data(withJSONObject: ["payload": payloadB64, "sig": sig]) |
| 32 | let row = CheckpointRow(id: id, seq_from: seqFrom, seq_to: seqTo, root: root, |
| 33 | chain: rowChain ?? chain, ts: 1_700_000_000, signed: String(data: wrapper, encoding: .utf8)!) |
| 34 | return BuiltRow(row: row, chain: chain) |
| 35 | } |
| 36 | |
| 37 | // MARK: pure core |
| 38 | |
| 39 | func testChainHashMatchesGoAndWebVector() { |
| 40 | let got = Checkpoint.chainHashHex(prev: "", root: "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0", seqTo: 2) |
| 41 | XCTAssertEqual(got, "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e") |
| 42 | } |
| 43 | |
| 44 | func testVerifiesWellFormedTwoLinkPage() { |
| 45 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 46 | let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) |
| 47 | let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: true) |
| 48 | XCTAssertEqual(out.alerts.count, 0) |
| 49 | XCTAssertFalse(out.fatal) |
| 50 | XCTAssertEqual(out.head?.seqTo, 9) |
| 51 | XCTAssertEqual(out.head?.pub, pubB64) |
| 52 | XCTAssertEqual(out.hist.map { $0.seqTo }, [3, 9]) |
| 53 | } |
| 54 | |
| 55 | func testTamperedSignatureStopsWalk() { |
| 56 | let bad = Data(repeating: 0, count: 64).base64EncodedString() |
| 57 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", badSig: bad) |
| 58 | let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: true) |
| 59 | XCTAssertEqual(out.alerts.first?.kind, "bad-signature") |
| 60 | XCTAssertTrue(out.fatal) |
| 61 | XCTAssertNil(out.head) |
| 62 | } |
| 63 | |
| 64 | func testGarbageSigFieldAlertsNeverThrows() { |
| 65 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", badSig: "!!not-base64!!") |
| 66 | let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: true) |
| 67 | XCTAssertEqual(out.alerts.first?.kind, "bad-signature") |
| 68 | XCTAssertTrue(out.fatal) |
| 69 | } |
| 70 | |
| 71 | func testKeyRotationAlertsButWalkContinues() { |
| 72 | let keyB = Curve25519.Signing.PrivateKey() |
| 73 | let pubBB64 = keyB.publicKey.rawRepresentation.base64EncodedString() |
| 74 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 75 | let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain, signKey: keyB, pub: pubBB64) |
| 76 | let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: true) |
| 77 | XCTAssertEqual(out.alerts.count, 1) |
| 78 | XCTAssertEqual(out.alerts.first?.kind, "key-change") |
| 79 | XCTAssertFalse(out.fatal) |
| 80 | XCTAssertEqual(out.head?.seqTo, 9) |
| 81 | XCTAssertEqual(out.head?.pub, pubBB64) |
| 82 | } |
| 83 | |
| 84 | func testBrokenLinkStopsWalk() { |
| 85 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 86 | let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: String(repeating: "f", count: 64)) |
| 87 | let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: false) |
| 88 | XCTAssertEqual(out.alerts.first?.kind, "broken-link") |
| 89 | XCTAssertEqual(out.head?.seqTo, 3) // c1 stands, c2 rejected |
| 90 | } |
| 91 | |
| 92 | func testRowColumnsDriftingStopsWalk() { |
| 93 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", rowChain: String(repeating: "0", count: 64)) |
| 94 | let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: false) |
| 95 | XCTAssertEqual(out.alerts.first?.kind, "row-drift") |
| 96 | } |
| 97 | |
| 98 | func testGossipVerdicts() { |
| 99 | let head = CkptHead(id: 2, seqTo: 9, chain: "c9", pub: "x") |
| 100 | let hist = [CkptGossip(seqTo: 3, chain: "c3"), CkptGossip(seqTo: 9, chain: "c9")] |
| 101 | func v(_ s: Int64, _ c: String) -> String { Checkpoint.gossipVerdict(head: head, hist: hist, gossip: CkptGossip(seqTo: s, chain: c)) } |
| 102 | XCTAssertEqual(v(9, "c9"), "match") |
| 103 | XCTAssertEqual(v(9, "XX"), "fork") |
| 104 | XCTAssertEqual(v(12, "cX"), "ahead") |
| 105 | XCTAssertEqual(v(3, "c3"), "match") |
| 106 | XCTAssertEqual(v(3, "XX"), "fork") |
| 107 | XCTAssertEqual(v(5, "c5"), "fork") // inside window, not one of our boundaries |
| 108 | XCTAssertEqual(v(2, "c2"), "unknown-old") // below the kept window |
| 109 | XCTAssertEqual(Checkpoint.gossipVerdict(head: nil, hist: [], gossip: CkptGossip(seqTo: 9, chain: "c9")), "no-head") |
| 110 | } |
| 111 | |
| 112 | // MARK: gossip wire tolerance (cross-platform contract) |
| 113 | |
| 114 | func testGossipEncodesAsHeterogeneousArray() throws { |
| 115 | let g = CkptGossip(seqTo: 31, chain: "ef37d7a412bd") |
| 116 | let data = try JSONEncoder().encode(g) |
| 117 | XCTAssertEqual(String(data: data, encoding: .utf8), "[31,\"ef37d7a412bd\"]") |
| 118 | let back = try JSONDecoder().decode(CkptGossip.self, from: Data("[31,\"ef37d7a412bd\"]".utf8)) |
| 119 | XCTAssertEqual(back, g) |
| 120 | } |
| 121 | |
| 122 | func testMessagePayloadToleratesCkpt() throws { |
| 123 | // A payload carrying _ckpt must decode with the field read and every |
| 124 | // other field intact — the web strips it before folds, but native must |
| 125 | // at least tolerate + read it. |
| 126 | let json = """ |
| 127 | {"name":"Alice","text":"hi","ts":1700000000000,"uid":"u1","_ckpt":[9,"abc123"]} |
| 128 | """ |
| 129 | let p = try JSONDecoder().decode(MessagePayload.self, from: Data(json.utf8)) |
| 130 | XCTAssertEqual(p.text, "hi") |
| 131 | XCTAssertEqual(p.name, "Alice") |
| 132 | XCTAssertEqual(p._ckpt, CkptGossip(seqTo: 9, chain: "abc123")) |
| 133 | // A payload without _ckpt leaves it nil (encode omits it). |
| 134 | let p2 = try JSONDecoder().decode(MessagePayload.self, from: Data(#"{"text":"hi"}"#.utf8)) |
| 135 | XCTAssertNil(p2._ckpt) |
| 136 | } |
| 137 | |
| 138 | // MARK: monitor |
| 139 | |
| 140 | private final class MemStore: IntegrityStore { |
| 141 | var data: Data? |
| 142 | func loadIntegrity() -> Data? { data } |
| 143 | func saveIntegrity(_ d: Data) { data = d } |
| 144 | } |
| 145 | |
| 146 | @MainActor |
| 147 | func testPollAdvancesHeadAndGossipsOut() async { |
| 148 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 149 | let mon = IntegrityMonitor(store: MemStore()) |
| 150 | await mon.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } |
| 151 | XCTAssertEqual(mon.gossipOut(), CkptGossip(seqTo: 3, chain: c1.chain)) |
| 152 | XCTAssertEqual(mon.unresolvedAlerts().count, 0) |
| 153 | } |
| 154 | |
| 155 | @MainActor |
| 156 | func testVanishedHeadIsRollback() async { |
| 157 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 158 | let mon = IntegrityMonitor(store: MemStore()) |
| 159 | var serve = [c1.row] |
| 160 | await mon.poll { _ in CheckpointsResponse(checkpoints: serve, public_key: self.pubB64) } |
| 161 | serve = [] |
| 162 | await mon.poll { _ in CheckpointsResponse(checkpoints: serve, public_key: self.pubB64) } |
| 163 | XCTAssertEqual(mon.unresolvedAlerts().first?.kind, "rollback") |
| 164 | } |
| 165 | |
| 166 | @MainActor |
| 167 | func testForkGossipAlertsAndBannerAcknowledgeReRaises() async { |
| 168 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 169 | let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) |
| 170 | var fired: [String] = [] |
| 171 | var serve = [c1.row] |
| 172 | let mon = IntegrityMonitor(store: MemStore()) |
| 173 | mon.onAlert = { fired.append($0.kind) } |
| 174 | await mon.poll { _ in CheckpointsResponse(checkpoints: serve.filter { $0.id > 0 }, public_key: self.pubB64) } |
| 175 | // Forged gossip at our head → fork alert + banner. |
| 176 | XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")), "fork") |
| 177 | XCTAssertEqual(fired, ["gossip-fork"]) |
| 178 | XCTAssertEqual(mon.unresolvedAlerts().count, 1) |
| 179 | // Same-seq re-see is deduped. |
| 180 | _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong2")) |
| 181 | XCTAssertEqual(fired.count, 1) |
| 182 | // Acknowledge clears the banner; the record stays. |
| 183 | mon.acknowledge() |
| 184 | XCTAssertEqual(mon.unresolvedAlerts().count, 0) |
| 185 | XCTAssertEqual(mon.report().alertCount, 1) |
| 186 | // A fork at a NEW head re-raises past the acknowledgement. |
| 187 | serve = [c1.row, c2.row] |
| 188 | await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| 189 | XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 9, chain: "wrong3")), "fork") |
| 190 | XCTAssertEqual(mon.unresolvedAlerts().count, 1) |
| 191 | XCTAssertEqual(fired.count, 2) |
| 192 | } |
| 193 | |
| 194 | @MainActor |
| 195 | func testAheadResolvesThenWithholds() async { |
| 196 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 197 | let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) |
| 198 | var t: Int64 = 0 |
| 199 | var serve = [c1.row] |
| 200 | let mon = IntegrityMonitor(store: MemStore(), now: { t }) |
| 201 | await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| 202 | // Peer verified c2's head before we saw it → pending, not an alert. |
| 203 | XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 9, chain: c2.chain)), "ahead") |
| 204 | serve = [c1.row, c2.row] |
| 205 | await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| 206 | XCTAssertEqual(mon.unresolvedAlerts().count, 0) // caught up, matched, resolved |
| 207 | // A peer ahead of a head the server never shows us → withheld after grace. |
| 208 | XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 20, chain: String(repeating: "x", count: 64))), "ahead") |
| 209 | t += IntegrityMonitor.aheadGraceMs + 1 |
| 210 | await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| 211 | XCTAssertEqual(mon.unresolvedAlerts().last?.kind, "withheld") |
| 212 | } |
| 213 | |
| 214 | @MainActor |
| 215 | func testReportLinesPluraliseAndReflectState() async { |
| 216 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 217 | let mon = IntegrityMonitor(store: MemStore()) |
| 218 | // No head yet. |
| 219 | XCTAssertTrue(mon.reportLines().contains { $0.contains("No verified checkpoints yet") }) |
| 220 | await mon.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } |
| 221 | // One peer, one alert → singular nouns. |
| 222 | _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")) |
| 223 | let one = mon.reportLines().joined(separator: "\n") |
| 224 | XCTAssertTrue(one.contains("Verified up to entry 3")) |
| 225 | XCTAssertTrue(one.contains("Signatures: verified (Ed25519)")) |
| 226 | XCTAssertTrue(one.contains("1 peer's record")) // singular |
| 227 | XCTAssertTrue(one.contains("1 integrity alert —")) // singular, no "s" |
| 228 | // A second, different peer + alert → plural nouns. |
| 229 | _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong"), countPeer: true) // deduped alert, still counts a peer |
| 230 | _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "match-miss")) |
| 231 | // Force a distinct second alert kind for the plural check. |
| 232 | var empty = [CheckpointRow]() |
| 233 | await mon.poll { _ in CheckpointsResponse(checkpoints: empty, public_key: self.pubB64) } |
| 234 | empty = [] |
| 235 | await mon.poll { _ in CheckpointsResponse(checkpoints: [], public_key: self.pubB64) } |
| 236 | let two = mon.reportLines().joined(separator: "\n") |
| 237 | XCTAssertTrue(two.contains("peers' records")) // plural |
| 238 | XCTAssertFalse(mon.reportLines().isEmpty) |
| 239 | } |
| 240 | |
| 241 | func testAlertMessagesAreMemberFacing() { |
| 242 | XCTAssertTrue(IntegrityMonitor.alertMessage("gossip-fork").contains("disagrees with another member")) |
| 243 | XCTAssertTrue(IntegrityMonitor.alertMessage("rollback").contains("older version")) |
| 244 | XCTAssertEqual(IntegrityMonitor.alertMessage("something-new"), "This space failed an integrity check.") |
| 245 | } |
| 246 | |
| 247 | @MainActor |
| 248 | func testStatePersistsAcrossMonitors() async { |
| 249 | let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| 250 | let shared = MemStore() |
| 251 | let m1 = IntegrityMonitor(store: shared) |
| 252 | await m1.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } |
| 253 | _ = m1.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")) // record a fork |
| 254 | let m2 = IntegrityMonitor(store: shared) |
| 255 | XCTAssertEqual(m2.gossipOut(), CkptGossip(seqTo: 3, chain: c1.chain)) |
| 256 | XCTAssertEqual(m2.unresolvedAlerts().first?.kind, "gossip-fork") |
| 257 | } |
| 258 | } |
| 259 | |