eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import XCTest
2import 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.
10final 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