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 inclusion-proof + bind-MAC wire format (#573). The leaf,
6// root, and audit-path vectors here are the SAME frozen values checkpoint.go
7// (TestGenVectors) and integrity.test.mjs pin: if any of the three platforms
8// drifts, they disagree about a valid proof. Fix the code, not the vector.
9final class InclusionProofTests: XCTestCase {
10
11 // MARK: Merkle inclusion proofs
12
13 func testLeafHashMatchesVector() {
14 // journalLeafHash({seq:1, ts:1700000000, kind:"msg", msg_id:42, thread_id:"th", blob_hash:"ab"})
15 let got = Checkpoint.leafHashHex(seq: 1, ts: 1_700_000_000, kind: "msg", msgID: 42, blobHash: "ab", threadID: "th")
16 XCTAssertEqual(got, "e88f7b9ea2a1fdec4ce59c21467efcef2c1b7227b9618d5581c0923328143b15")
17 }
18
19 // The real 3-leaf tree from checkpoint.go's vector generator; proof for index 1.
20 private let ROOT = "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd"
21 private let AUDIT = [
22 "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e",
23 "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189",
24 ]
25 private func goodProof() -> InclusionProof {
26 InclusionProof(seq: 2, ts: 1_700_000_001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2",
27 checkpoint_id: 1, root: ROOT, leaf_index: 1, tree_size: 3, audit_path: AUDIT)
28 }
29
30 func testVerifyInclusionReproducesGoRoot() {
31 let (ok, root) = Checkpoint.verifyInclusion(goodProof())
32 XCTAssertTrue(ok)
33 XCTAssertEqual(root, ROOT)
34 }
35
36 func testTamperedProofDoesNotVerify() {
37 var p = goodProof()
38 // Wrong content (blob_hash) → different leaf → root won't match.
39 p = InclusionProof(seq: p.seq, ts: p.ts, kind: p.kind, msg_id: p.msg_id, thread_id: p.thread_id,
40 blob_hash: "hX", checkpoint_id: p.checkpoint_id, root: p.root,
41 leaf_index: p.leaf_index, tree_size: p.tree_size, audit_path: p.audit_path)
42 XCTAssertFalse(Checkpoint.verifyInclusion(p).ok)
43 // Corrupted sibling.
44 let corrupt = InclusionProof(seq: 2, ts: 1_700_000_001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2",
45 checkpoint_id: 1, root: ROOT, leaf_index: 1, tree_size: 3,
46 audit_path: [String(repeating: "00", count: 32), AUDIT[1]])
47 XCTAssertFalse(Checkpoint.verifyInclusion(corrupt).ok)
48 // Wrong path length for the tree size → nil root.
49 XCTAssertNil(Checkpoint.rootFromAuditPath(leafHex: String(repeating: "ab", count: 32), m: 1, n: 3, pathHex: [AUDIT[0]]))
50 }
51
52 func testAuditPathReproducesRootForEveryShape() {
53 // A self-contained sweep (like the Go test): build n leaves, prove each.
54 for n in 1...17 {
55 var leaves: [String] = []
56 for i in 0..<n {
57 leaves.append(Checkpoint.leafHashHex(seq: Int64(i), ts: Int64(n), kind: "k", msgID: Int64(i), blobHash: "b\(i)", threadID: "t"))
58 }
59 let root = merkleRootHex(leaves)
60 for m in 0..<n {
61 let path = auditPathHex(leaves, m)
62 let got = Checkpoint.rootFromAuditPath(leafHex: leaves[m], m: m, n: n, pathHex: path)
63 XCTAssertEqual(got, root, "n=\(n) m=\(m)")
64 }
65 }
66 }
67
68 // Local reference Merkle (RFC 6962) to cross-check rootFromAuditPath — mirrors
69 // checkpoint.go merkleRoot/merkleAuditPath, independent of the code under test.
70 private func nodeHex(_ l: String, _ r: String) -> String {
71 var d = Data("lchat-ckpt-node\n".utf8)
72 d.append(Data(hexBytes(l))); d.append(Data(hexBytes(r)))
73 return SHA256.hash(data: d).map { String(format: "%02x", $0) }.joined()
74 }
75 private func merkleRootHex(_ leaves: [String]) -> String {
76 if leaves.count == 1 { return leaves[0] }
77 var k = 1; while k * 2 < leaves.count { k *= 2 }
78 return nodeHex(merkleRootHex(Array(leaves[0..<k])), merkleRootHex(Array(leaves[k...])))
79 }
80 private func auditPathHex(_ leaves: [String], _ m: Int) -> [String] {
81 let n = leaves.count
82 if n <= 1 { return [] }
83 var k = 1; while k * 2 < n { k *= 2 }
84 if m < k { return auditPathHex(Array(leaves[0..<k]), m) + [merkleRootHex(Array(leaves[k...]))] }
85 return auditPathHex(Array(leaves[k...]), m - k) + [merkleRootHex(Array(leaves[0..<k]))]
86 }
87 private func hexBytes(_ s: String) -> [UInt8] {
88 var out = [UInt8](); var i = s.startIndex
89 while i < s.endIndex { let j = s.index(i, offsetBy: 2); out.append(UInt8(s[i..<j], radix: 16)!); i = j }
90 return out
91 }
92
93 // MARK: sampleAndVerify (monitor)
94
95 private final class MemStore: IntegrityStore {
96 var data: Data?
97 func loadIntegrity() -> Data? { data }
98 func saveIntegrity(_ d: Data) { data = d }
99 }
100
101 @MainActor
102 func testSampleAndVerifyConfirmsAndFlags() async {
103 let store = MemStore()
104 // Pre-seed a verified root (as a poll would) via the internal state.
105 var seed = IntegrityState()
106 seed.roots = [ROOT]
107 store.saveIntegrity(try! JSONEncoder().encode(seed))
108 let mon = IntegrityMonitor(store: store)
109
110 let r = await mon.sampleAndVerify([(msgID: 11, blobHash: "h2")]) { _ in
111 CheckpointProofResponse(proofs: [self.goodProof()], committed_to: 5)
112 }
113 XCTAssertEqual(r.verified, 1)
114 XCTAssertEqual(mon.unresolvedAlerts().count, 0)
115
116 // A forged proof against the trusted root → bad-proof alert.
117 let forged = InclusionProof(seq: 2, ts: 1_700_000_001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2",
118 checkpoint_id: 1, root: ROOT, leaf_index: 1, tree_size: 3,
119 audit_path: [String(repeating: "00", count: 32), AUDIT[1]])
120 _ = await mon.sampleAndVerify([(msgID: 11, blobHash: "h2")]) { _ in
121 CheckpointProofResponse(proofs: [forged], committed_to: 5)
122 }
123 XCTAssertTrue(mon.unresolvedAlerts().contains { $0.kind == "bad-proof" })
124 }
125
126 @MainActor
127 func testSampleWithNoTrustedRootsIsSilent() async {
128 let mon = IntegrityMonitor(store: MemStore()) // no roots verified yet
129 let r = await mon.sampleAndVerify([(msgID: 11, blobHash: "h2")]) { _ in
130 CheckpointProofResponse(proofs: [self.goodProof()], committed_to: 5)
131 }
132 XCTAssertEqual(r.checked, 0)
133 XCTAssertEqual(mon.unresolvedAlerts().count, 0)
134 }
135
136 // MARK: bind-MAC (key transparency)
137
138 func testBindKeyDeterministicAndDomainSeparated() throws {
139 let t = InviteCrypto.randomSecret()
140 let a = try InviteCrypto.derive(t)
141 let b = try InviteCrypto.derive(t)
142 XCTAssertEqual(a.bindKey, b.bindKey)
143 XCTAssertNotEqual(a.bindKey, a.claimSecret) // domain separation
144 let c = try InviteCrypto.derive(InviteCrypto.randomSecret())
145 XCTAssertNotEqual(a.bindKey, c.bindKey)
146 }
147
148 func testBindMacVerifiesAndRejectsSubstitution() throws {
149 let d = try InviteCrypto.derive(InviteCrypto.randomSecret())
150 let kp = ECIES.generateUserKeypair()
151 let mac = InviteCrypto.bindMac(bindKeyB64: d.bindKey, pubB64: kp.pub)
152 XCTAssertNotNil(mac)
153 XCTAssertTrue(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: kp.pub, macB64: mac!))
154 // A substituted key must not verify — the whole point.
155 let evil = ECIES.generateUserKeypair()
156 XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: evil.pub, macB64: mac!))
157 // Wrong bind key rejected.
158 let other = try InviteCrypto.derive(InviteCrypto.randomSecret())
159 XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: other.bindKey, pubB64: kp.pub, macB64: mac!))
160 // Malformed never throws → false.
161 XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: kp.pub, macB64: "not base64!!"))
162 XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: "", pubB64: kp.pub, macB64: mac!))
163 }
164
165 // The web/Swift bind-MAC must interoperate: a MAC one produces the other
166 // verifies. Cross-check against a value the JS test could reproduce (MAC
167 // over decoded key bytes) via a fixed key + pub.
168 func testBindMacIsOverDecodedBytesNotString() throws {
169 let d = try InviteCrypto.derive(InviteCrypto.randomSecret())
170 let kp = ECIES.generateUserKeypair()
171 let mac = InviteCrypto.bindMac(bindKeyB64: d.bindKey, pubB64: kp.pub)!
172 // Re-encode the SAME key bytes with '=' padding — a server re-encoding
173 // trick that must NOT break verification (bind is over bytes, not string).
174 let padded = kp.pub + "="
175 XCTAssertTrue(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: padded, macB64: mac))
176 }
177}
178