import XCTest import CryptoKit @testable import LChatCore // Native half of the tamper-evidence wire format (#573 slices 1+2). The // chain-hash vector here is the SAME frozen value checkpoint_test.go // (TestCheckpointHashVectors) and integrity.test.mjs pin: if any of the three // drifts, the platforms disagree about what a valid chain is. Fix the code, // not the vector. final class CheckpointTests: XCTestCase { // MARK: test key + checkpoint builder private let key = Curve25519.Signing.PrivateKey() private var pubB64: String { key.publicKey.rawRepresentation.base64EncodedString() } private struct BuiltRow { let row: CheckpointRow; let chain: String } private func mkCheckpoint(id: Int64, seqFrom: Int64, seqTo: Int64, prevChain: String, signKey: Curve25519.Signing.PrivateKey? = nil, pub: String? = nil, badSig: String? = nil, rowChain: String? = nil) -> BuiltRow { let root = String(String(repeating: String(seqTo), count: 4).prefix(64)).padding(toLength: 64, withPad: "a", startingAt: 0) let chain = Checkpoint.chainHashHex(prev: prevChain, root: root, seqTo: seqTo) let payload: [String: Any] = [ "seq_from": seqFrom, "seq_to": seqTo, "root": root, "prev_chain": prevChain, "chain": chain, "ts": 1_700_000_000, "pub": pub ?? pubB64, ] let payloadData = try! JSONSerialization.data(withJSONObject: payload) let payloadB64 = payloadData.base64EncodedString() let sig = try! badSig ?? (signKey ?? key).signature(for: Data(payloadB64.utf8)).base64EncodedString() let wrapper = try! JSONSerialization.data(withJSONObject: ["payload": payloadB64, "sig": sig]) let row = CheckpointRow(id: id, seq_from: seqFrom, seq_to: seqTo, root: root, chain: rowChain ?? chain, ts: 1_700_000_000, signed: String(data: wrapper, encoding: .utf8)!) return BuiltRow(row: row, chain: chain) } // MARK: pure core func testChainHashMatchesGoAndWebVector() { let got = Checkpoint.chainHashHex(prev: "", root: "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0", seqTo: 2) XCTAssertEqual(got, "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e") } func testVerifiesWellFormedTwoLinkPage() { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: true) XCTAssertEqual(out.alerts.count, 0) XCTAssertFalse(out.fatal) XCTAssertEqual(out.head?.seqTo, 9) XCTAssertEqual(out.head?.pub, pubB64) XCTAssertEqual(out.hist.map { $0.seqTo }, [3, 9]) } func testTamperedSignatureStopsWalk() { let bad = Data(repeating: 0, count: 64).base64EncodedString() let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", badSig: bad) let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: true) XCTAssertEqual(out.alerts.first?.kind, "bad-signature") XCTAssertTrue(out.fatal) XCTAssertNil(out.head) } func testGarbageSigFieldAlertsNeverThrows() { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", badSig: "!!not-base64!!") let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: true) XCTAssertEqual(out.alerts.first?.kind, "bad-signature") XCTAssertTrue(out.fatal) } func testKeyRotationAlertsButWalkContinues() { let keyB = Curve25519.Signing.PrivateKey() let pubBB64 = keyB.publicKey.rawRepresentation.base64EncodedString() let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain, signKey: keyB, pub: pubBB64) let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: true) XCTAssertEqual(out.alerts.count, 1) XCTAssertEqual(out.alerts.first?.kind, "key-change") XCTAssertFalse(out.fatal) XCTAssertEqual(out.head?.seqTo, 9) XCTAssertEqual(out.head?.pub, pubBB64) } func testBrokenLinkStopsWalk() { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: String(repeating: "f", count: 64)) let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: false) XCTAssertEqual(out.alerts.first?.kind, "broken-link") XCTAssertEqual(out.head?.seqTo, 3) // c1 stands, c2 rejected } func testRowColumnsDriftingStopsWalk() { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", rowChain: String(repeating: "0", count: 64)) let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: false) XCTAssertEqual(out.alerts.first?.kind, "row-drift") } func testGossipVerdicts() { let head = CkptHead(id: 2, seqTo: 9, chain: "c9", pub: "x") let hist = [CkptGossip(seqTo: 3, chain: "c3"), CkptGossip(seqTo: 9, chain: "c9")] func v(_ s: Int64, _ c: String) -> String { Checkpoint.gossipVerdict(head: head, hist: hist, gossip: CkptGossip(seqTo: s, chain: c)) } XCTAssertEqual(v(9, "c9"), "match") XCTAssertEqual(v(9, "XX"), "fork") XCTAssertEqual(v(12, "cX"), "ahead") XCTAssertEqual(v(3, "c3"), "match") XCTAssertEqual(v(3, "XX"), "fork") XCTAssertEqual(v(5, "c5"), "fork") // inside window, not one of our boundaries XCTAssertEqual(v(2, "c2"), "unknown-old") // below the kept window XCTAssertEqual(Checkpoint.gossipVerdict(head: nil, hist: [], gossip: CkptGossip(seqTo: 9, chain: "c9")), "no-head") } // MARK: gossip wire tolerance (cross-platform contract) func testGossipEncodesAsHeterogeneousArray() throws { let g = CkptGossip(seqTo: 31, chain: "ef37d7a412bd") let data = try JSONEncoder().encode(g) XCTAssertEqual(String(data: data, encoding: .utf8), "[31,\"ef37d7a412bd\"]") let back = try JSONDecoder().decode(CkptGossip.self, from: Data("[31,\"ef37d7a412bd\"]".utf8)) XCTAssertEqual(back, g) } func testMessagePayloadToleratesCkpt() throws { // A payload carrying _ckpt must decode with the field read and every // other field intact — the web strips it before folds, but native must // at least tolerate + read it. let json = """ {"name":"Alice","text":"hi","ts":1700000000000,"uid":"u1","_ckpt":[9,"abc123"]} """ let p = try JSONDecoder().decode(MessagePayload.self, from: Data(json.utf8)) XCTAssertEqual(p.text, "hi") XCTAssertEqual(p.name, "Alice") XCTAssertEqual(p._ckpt, CkptGossip(seqTo: 9, chain: "abc123")) // A payload without _ckpt leaves it nil (encode omits it). let p2 = try JSONDecoder().decode(MessagePayload.self, from: Data(#"{"text":"hi"}"#.utf8)) XCTAssertNil(p2._ckpt) } // MARK: monitor private final class MemStore: IntegrityStore { var data: Data? func loadIntegrity() -> Data? { data } func saveIntegrity(_ d: Data) { data = d } } @MainActor func testPollAdvancesHeadAndGossipsOut() async { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let mon = IntegrityMonitor(store: MemStore()) await mon.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } XCTAssertEqual(mon.gossipOut(), CkptGossip(seqTo: 3, chain: c1.chain)) XCTAssertEqual(mon.unresolvedAlerts().count, 0) } @MainActor func testVanishedHeadIsRollback() async { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let mon = IntegrityMonitor(store: MemStore()) var serve = [c1.row] await mon.poll { _ in CheckpointsResponse(checkpoints: serve, public_key: self.pubB64) } serve = [] await mon.poll { _ in CheckpointsResponse(checkpoints: serve, public_key: self.pubB64) } XCTAssertEqual(mon.unresolvedAlerts().first?.kind, "rollback") } @MainActor func testForkGossipAlertsAndBannerAcknowledgeReRaises() async { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) var fired: [String] = [] var serve = [c1.row] let mon = IntegrityMonitor(store: MemStore()) mon.onAlert = { fired.append($0.kind) } await mon.poll { _ in CheckpointsResponse(checkpoints: serve.filter { $0.id > 0 }, public_key: self.pubB64) } // Forged gossip at our head → fork alert + banner. XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")), "fork") XCTAssertEqual(fired, ["gossip-fork"]) XCTAssertEqual(mon.unresolvedAlerts().count, 1) // Same-seq re-see is deduped. _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong2")) XCTAssertEqual(fired.count, 1) // Acknowledge clears the banner; the record stays. mon.acknowledge() XCTAssertEqual(mon.unresolvedAlerts().count, 0) XCTAssertEqual(mon.report().alertCount, 1) // A fork at a NEW head re-raises past the acknowledgement. serve = [c1.row, c2.row] await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 9, chain: "wrong3")), "fork") XCTAssertEqual(mon.unresolvedAlerts().count, 1) XCTAssertEqual(fired.count, 2) } @MainActor func testAheadResolvesThenWithholds() async { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) var t: Int64 = 0 var serve = [c1.row] let mon = IntegrityMonitor(store: MemStore(), now: { t }) await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } // Peer verified c2's head before we saw it → pending, not an alert. XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 9, chain: c2.chain)), "ahead") serve = [c1.row, c2.row] await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } XCTAssertEqual(mon.unresolvedAlerts().count, 0) // caught up, matched, resolved // A peer ahead of a head the server never shows us → withheld after grace. XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 20, chain: String(repeating: "x", count: 64))), "ahead") t += IntegrityMonitor.aheadGraceMs + 1 await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } XCTAssertEqual(mon.unresolvedAlerts().last?.kind, "withheld") } @MainActor func testReportLinesPluraliseAndReflectState() async { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let mon = IntegrityMonitor(store: MemStore()) // No head yet. XCTAssertTrue(mon.reportLines().contains { $0.contains("No verified checkpoints yet") }) await mon.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } // One peer, one alert → singular nouns. _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")) let one = mon.reportLines().joined(separator: "\n") XCTAssertTrue(one.contains("Verified up to entry 3")) XCTAssertTrue(one.contains("Signatures: verified (Ed25519)")) XCTAssertTrue(one.contains("1 peer's record")) // singular XCTAssertTrue(one.contains("1 integrity alert —")) // singular, no "s" // A second, different peer + alert → plural nouns. _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong"), countPeer: true) // deduped alert, still counts a peer _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "match-miss")) // Force a distinct second alert kind for the plural check. var empty = [CheckpointRow]() await mon.poll { _ in CheckpointsResponse(checkpoints: empty, public_key: self.pubB64) } empty = [] await mon.poll { _ in CheckpointsResponse(checkpoints: [], public_key: self.pubB64) } let two = mon.reportLines().joined(separator: "\n") XCTAssertTrue(two.contains("peers' records")) // plural XCTAssertFalse(mon.reportLines().isEmpty) } func testAlertMessagesAreMemberFacing() { XCTAssertTrue(IntegrityMonitor.alertMessage("gossip-fork").contains("disagrees with another member")) XCTAssertTrue(IntegrityMonitor.alertMessage("rollback").contains("older version")) XCTAssertEqual(IntegrityMonitor.alertMessage("something-new"), "This space failed an integrity check.") } @MainActor func testStatePersistsAcrossMonitors() async { let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") let shared = MemStore() let m1 = IntegrityMonitor(store: shared) await m1.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } _ = m1.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")) // record a fork let m2 = IntegrityMonitor(store: shared) XCTAssertEqual(m2.gossipOut(), CkptGossip(seqTo: 3, chain: c1.chain)) XCTAssertEqual(m2.unresolvedAlerts().first?.kind, "gossip-fork") } }