import XCTest import CryptoKit @testable import LChatCore // Native half of the inclusion-proof + bind-MAC wire format (#573). The leaf, // root, and audit-path vectors here are the SAME frozen values checkpoint.go // (TestGenVectors) and integrity.test.mjs pin: if any of the three platforms // drifts, they disagree about a valid proof. Fix the code, not the vector. final class InclusionProofTests: XCTestCase { // MARK: Merkle inclusion proofs func testLeafHashMatchesVector() { // journalLeafHash({seq:1, ts:1700000000, kind:"msg", msg_id:42, thread_id:"th", blob_hash:"ab"}) let got = Checkpoint.leafHashHex(seq: 1, ts: 1_700_000_000, kind: "msg", msgID: 42, blobHash: "ab", threadID: "th") XCTAssertEqual(got, "e88f7b9ea2a1fdec4ce59c21467efcef2c1b7227b9618d5581c0923328143b15") } // The real 3-leaf tree from checkpoint.go's vector generator; proof for index 1. private let ROOT = "2d87c04aa784c70ba78cfae713e3434cbd4bea6da2644246b81e5e89240f02dd" private let AUDIT = [ "9da3202f6b64da0ed851e92377b58188b5928facc765337938adb1bb32639e3e", "7a734103ada2f12e8d051e1c894fbf5f3a0a8049c26b4bc598b49c2c4da19189", ] private func goodProof() -> InclusionProof { InclusionProof(seq: 2, ts: 1_700_000_001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", checkpoint_id: 1, root: ROOT, leaf_index: 1, tree_size: 3, audit_path: AUDIT) } func testVerifyInclusionReproducesGoRoot() { let (ok, root) = Checkpoint.verifyInclusion(goodProof()) XCTAssertTrue(ok) XCTAssertEqual(root, ROOT) } func testTamperedProofDoesNotVerify() { var p = goodProof() // Wrong content (blob_hash) → different leaf → root won't match. p = InclusionProof(seq: p.seq, ts: p.ts, kind: p.kind, msg_id: p.msg_id, thread_id: p.thread_id, blob_hash: "hX", checkpoint_id: p.checkpoint_id, root: p.root, leaf_index: p.leaf_index, tree_size: p.tree_size, audit_path: p.audit_path) XCTAssertFalse(Checkpoint.verifyInclusion(p).ok) // Corrupted sibling. let corrupt = InclusionProof(seq: 2, ts: 1_700_000_001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", checkpoint_id: 1, root: ROOT, leaf_index: 1, tree_size: 3, audit_path: [String(repeating: "00", count: 32), AUDIT[1]]) XCTAssertFalse(Checkpoint.verifyInclusion(corrupt).ok) // Wrong path length for the tree size → nil root. XCTAssertNil(Checkpoint.rootFromAuditPath(leafHex: String(repeating: "ab", count: 32), m: 1, n: 3, pathHex: [AUDIT[0]])) } func testAuditPathReproducesRootForEveryShape() { // A self-contained sweep (like the Go test): build n leaves, prove each. for n in 1...17 { var leaves: [String] = [] for i in 0.. String { var d = Data("lchat-ckpt-node\n".utf8) d.append(Data(hexBytes(l))); d.append(Data(hexBytes(r))) return SHA256.hash(data: d).map { String(format: "%02x", $0) }.joined() } private func merkleRootHex(_ leaves: [String]) -> String { if leaves.count == 1 { return leaves[0] } var k = 1; while k * 2 < leaves.count { k *= 2 } return nodeHex(merkleRootHex(Array(leaves[0.. [String] { let n = leaves.count if n <= 1 { return [] } var k = 1; while k * 2 < n { k *= 2 } if m < k { return auditPathHex(Array(leaves[0.. [UInt8] { var out = [UInt8](); var i = s.startIndex while i < s.endIndex { let j = s.index(i, offsetBy: 2); out.append(UInt8(s[i.. Data? { data } func saveIntegrity(_ d: Data) { data = d } } @MainActor func testSampleAndVerifyConfirmsAndFlags() async { let store = MemStore() // Pre-seed a verified root (as a poll would) via the internal state. var seed = IntegrityState() seed.roots = [ROOT] store.saveIntegrity(try! JSONEncoder().encode(seed)) let mon = IntegrityMonitor(store: store) let r = await mon.sampleAndVerify([(msgID: 11, blobHash: "h2")]) { _ in CheckpointProofResponse(proofs: [self.goodProof()], committed_to: 5) } XCTAssertEqual(r.verified, 1) XCTAssertEqual(mon.unresolvedAlerts().count, 0) // A forged proof against the trusted root → bad-proof alert. let forged = InclusionProof(seq: 2, ts: 1_700_000_001, kind: "msg", msg_id: 11, thread_id: "t", blob_hash: "h2", checkpoint_id: 1, root: ROOT, leaf_index: 1, tree_size: 3, audit_path: [String(repeating: "00", count: 32), AUDIT[1]]) _ = await mon.sampleAndVerify([(msgID: 11, blobHash: "h2")]) { _ in CheckpointProofResponse(proofs: [forged], committed_to: 5) } XCTAssertTrue(mon.unresolvedAlerts().contains { $0.kind == "bad-proof" }) } @MainActor func testSampleWithNoTrustedRootsIsSilent() async { let mon = IntegrityMonitor(store: MemStore()) // no roots verified yet let r = await mon.sampleAndVerify([(msgID: 11, blobHash: "h2")]) { _ in CheckpointProofResponse(proofs: [self.goodProof()], committed_to: 5) } XCTAssertEqual(r.checked, 0) XCTAssertEqual(mon.unresolvedAlerts().count, 0) } // MARK: bind-MAC (key transparency) func testBindKeyDeterministicAndDomainSeparated() throws { let t = InviteCrypto.randomSecret() let a = try InviteCrypto.derive(t) let b = try InviteCrypto.derive(t) XCTAssertEqual(a.bindKey, b.bindKey) XCTAssertNotEqual(a.bindKey, a.claimSecret) // domain separation let c = try InviteCrypto.derive(InviteCrypto.randomSecret()) XCTAssertNotEqual(a.bindKey, c.bindKey) } func testBindMacVerifiesAndRejectsSubstitution() throws { let d = try InviteCrypto.derive(InviteCrypto.randomSecret()) let kp = ECIES.generateUserKeypair() let mac = InviteCrypto.bindMac(bindKeyB64: d.bindKey, pubB64: kp.pub) XCTAssertNotNil(mac) XCTAssertTrue(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: kp.pub, macB64: mac!)) // A substituted key must not verify — the whole point. let evil = ECIES.generateUserKeypair() XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: evil.pub, macB64: mac!)) // Wrong bind key rejected. let other = try InviteCrypto.derive(InviteCrypto.randomSecret()) XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: other.bindKey, pubB64: kp.pub, macB64: mac!)) // Malformed never throws → false. XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: kp.pub, macB64: "not base64!!")) XCTAssertFalse(InviteCrypto.verifyBindMac(bindKeyB64: "", pubB64: kp.pub, macB64: mac!)) } // The web/Swift bind-MAC must interoperate: a MAC one produces the other // verifies. Cross-check against a value the JS test could reproduce (MAC // over decoded key bytes) via a fixed key + pub. func testBindMacIsOverDecodedBytesNotString() throws { let d = try InviteCrypto.derive(InviteCrypto.randomSecret()) let kp = ECIES.generateUserKeypair() let mac = InviteCrypto.bindMac(bindKeyB64: d.bindKey, pubB64: kp.pub)! // Re-encode the SAME key bytes with '=' padding — a server re-encoding // trick that must NOT break verification (bind is over bytes, not string). let padded = kp.pub + "=" XCTAssertTrue(InviteCrypto.verifyBindMac(bindKeyB64: d.bindKey, pubB64: padded, macB64: mac)) } }