native: inclusion-proof sampling + bind-MAC verify (#573) (#833)
Completes native parity for the two remaining tamper-evidence pieces
(web: #807 inclusion proofs, #813 bind-MAC).
Inclusion proofs (LChatCore, vector-pinned to Go+JS):
- Checkpoint.swift: leafHashHex / merkleNode / rootFromAuditPath /
verifyInclusion + InclusionProof type + blobHashHex — the exact mirror
of checkpoint.go / integrity.js (leaf e88f7b9e…, root 2d87c04a…).
- IntegrityMonitor: verifyPage now collects roots; poll accumulates them;
sampleAndVerify proves held messages against trusted roots (bad-proof
alert on a forged path; no-proof never alarmed); report + alertMessage.
- LChatAPI.checkpointProof(msgID:); NativeFeedModelCache.pollIntegrity now
re-fetches a couple of recent rows (native keeps no raw ciphertext),
hashes them, and samples.
Bind-MAC / key transparency:
- InviteCrypto: bindKey = HKDF(T,"lchat-invite-bind") + bindMac/verifyBindMac
(HMAC-SHA256 over DECODED pubkey bytes — a re-encoded roster string can't
break an honest verify).
- Joiner publishes bind_mac at claim (SpaceClaim → ensurePubkey; fresh-person
only). RosterEntry gains bind_mac.
- Inviter (ThreadStore.inviteSomeone) stashes the invitee's bind key; on every
roster load verifyInviteBindings confirms the roster key is the one they
published, else recordAlert("key-substitution") — advisory, never blocks;
re-checks each load; pre-KT = unverified not a failure.
Tests: InclusionProofTests (cross-platform leaf/root vectors, audit-path sweep
n≤17, forged-proof rejection, sampleAndVerify verified+bad-proof+untrusted-skip,
bind-MAC verify/substitution-reject/canonical-bytes/domain-sep). swift test 277
pass; iOS + Mac BUILD SUCCEEDED. No changelog until the native release is minted.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+474
−13
ios/LChatCore/Sources/LChatCore/Checkpoint.swift+103 −1ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift+59 −0ios/LChatCore/Sources/LChatCore/InviteCrypto.swift+29 −1ios/LChatCore/Sources/LChatCore/LChatAPI.swift+23 −8ios/LChatCore/Sources/LChatCore/Models.swift+4 −0ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+23 −2ios/LChatCore/Sources/LChatCore/SpaceCreate.swift+7 −1ios/LChatCore/Sources/LChatCore/ThreadStore.swift+49 −0ios/LChatCore/Tests/LChatCoreTests/InclusionProofTests.swift+177 −0
diff --git a/ios/LChatCore/Sources/LChatCore/Checkpoint.swift b/ios/LChatCore/Sources/LChatCore/Checkpoint.swift| index 1be020e..e8db107 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Checkpoint.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Checkpoint.swift |
| @@ -92,10 +92,35 @@ public struct CkptAlert: Codable, Sendable, Equatable { |
| public struct CkptPageResult: Sendable { |
| public var head: CkptHead? |
| public var hist: [CkptGossip] // verified (seqTo, chain) boundary pairs |
| + public var roots: [String] // verified checkpoint roots (inclusion-proof trust) |
| public var alerts: [CkptAlert] |
| public var fatal: Bool // a non-`keep` alert stopped the walk |
| } |
| +/// One journal event proven to be in a committed checkpoint's Merkle tree |
| +/// (docs/tamper-evidence.md client sampling). The wire shape from |
| +/// GET /api/checkpoint-proof (checkpoint.go inclusionProof). The client |
| +/// recomputes the leaf from the row fields, walks the audit path, and checks |
| +/// the root against a checkpoint it already trusts via the signed chain. |
| +public struct InclusionProof: Codable, Sendable { |
| + public let seq: Int64 |
| + public let ts: Int64 |
| + public let kind: String |
| + public let msg_id: Int64 |
| + public let thread_id: String |
| + public let blob_hash: String |
| + public let checkpoint_id: Int64 |
| + public let root: String |
| + public let leaf_index: Int |
| + public let tree_size: Int |
| + public let audit_path: [String] // hex sibling hashes, bottom-up |
| +} |
| + |
| +public struct CheckpointProofResponse: Codable, Sendable { |
| + public let proofs: [InclusionProof]? |
| + public let committed_to: Int64? |
| +} |
| + |
| // MARK: - Pure verification core (nonisolated, vector-pinned) |
| public enum Checkpoint { |
| @@ -130,6 +155,7 @@ public enum Checkpoint { |
| public static func verifyPage(_ rows: [CheckpointRow], prior: CkptHead?, checkSigs: Bool) -> CkptPageResult { |
| var alerts: [CkptAlert] = [] |
| var hist: [CkptGossip] = [] |
| + var roots: [String] = [] // root is chain-hash-bound below, so trusting it is sound |
| var head = prior |
| var pub = prior?.pub |
| let now = Int64(Date().timeIntervalSince1970) |
| @@ -174,9 +200,85 @@ public enum Checkpoint { |
| pub = payload.pub |
| head = CkptHead(id: cp.id, seqTo: payload.seq_to, chain: payload.chain, pub: payload.pub) |
| hist.append(CkptGossip(seqTo: payload.seq_to, chain: payload.chain)) |
| + roots.append(payload.root) |
| } |
| let fatal = alerts.contains { $0.keep != true } |
| - return CkptPageResult(head: head, hist: hist, alerts: alerts, fatal: fatal) |
| + return CkptPageResult(head: head, hist: hist, roots: roots, alerts: alerts, fatal: fatal) |
| + } |
| + |
| + // MARK: - Inclusion proofs (mirror checkpoint.go / integrity.js) |
| + |
| + static let leafDomain = "lchat-ckpt-leaf\n" |
| + static let nodeDomain = "lchat-ckpt-node\n" |
| + |
| + private static func hex(_ d: [UInt8]) -> String { d.map { String(format: "%02x", $0) }.joined() } |
| + private static func fromHex(_ s: String) -> [UInt8]? { |
| + guard s.count % 2 == 0 else { return nil } |
| + var out = [UInt8](); out.reserveCapacity(s.count / 2) |
| + var i = s.startIndex |
| + while i < s.endIndex { |
| + let j = s.index(i, offsetBy: 2) |
| + guard let b = UInt8(s[i..<j], radix: 16) else { return nil } |
| + out.append(b); i = j |
| + } |
| + return out |
| + } |
| + |
| + /// leafHashHex mirrors journalLeafHash — the domain-separated hash of a |
| + /// journal row (the tree's leaf). Field order is wire format (checkpoint.go). |
| + public static func leafHashHex(seq: Int64, ts: Int64, kind: String, msgID: Int64, blobHash: String, threadID: String) -> String { |
| + let enc = leafDomain + String(seq) + "\n" + String(ts) + "\n" + kind + "\n" |
| + + String(msgID) + "\n" + blobHash + "\n" + threadID |
| + return hex(Array(SHA256.hash(data: Data(enc.utf8)))) |
| + } |
| + |
| + private static func nodeHash(_ l: [UInt8], _ r: [UInt8]) -> [UInt8] { |
| + var data = Data(nodeDomain.utf8); data.append(contentsOf: l); data.append(contentsOf: r) |
| + return Array(SHA256.hash(data: data)) |
| + } |
| + |
| + /// rootFromAuditPath recomputes the Merkle root from a leaf (hex), its index |
| + /// m in a tree of n leaves, and its audit path (hex siblings, bottom-up) — |
| + /// the exact mirror of checkpoint.go's rootFromAuditPath. Returns the root |
| + /// hex, or nil on a malformed proof. |
| + public static func rootFromAuditPath(leafHex: String, m: Int, n: Int, pathHex: [String]) -> String? { |
| + guard m >= 0, m < n, let leaf = fromHex(leafHex) else { return nil } |
| + var path: [[UInt8]] = [] |
| + for h in pathHex { guard let b = fromHex(h) else { return nil }; path.append(b) } |
| + func recompute(_ leaf: [UInt8], _ m: Int, _ n: Int, _ path: [[UInt8]]) -> [UInt8]? { |
| + if n == 1 { return path.isEmpty ? leaf : nil } |
| + if path.isEmpty { return nil } |
| + var k = 1 |
| + while k * 2 < n { k *= 2 } |
| + let sibling = path[path.count - 1] |
| + let rest = Array(path[0..<(path.count - 1)]) |
| + if m < k { |
| + guard let left = recompute(leaf, m, k, rest) else { return nil } |
| + return nodeHash(left, sibling) |
| + } |
| + guard let right = recompute(leaf, m - k, n - k, rest) else { return nil } |
| + return nodeHash(sibling, right) |
| + } |
| + guard let root = recompute(leaf, m, n, path) else { return nil } |
| + return hex(root) |
| + } |
| + |
| + /// verifyInclusion checks one proof: recompute the leaf from its row fields, |
| + /// walk the audit path, and confirm it reaches the proof's stated root. The |
| + /// caller separately checks proof.root is a root it TRUSTS (via the signed |
| + /// chain); this only proves the leaf is in that root's tree. Returns |
| + /// (ok, recomputed root). |
| + public static func verifyInclusion(_ p: InclusionProof) -> (ok: Bool, root: String?) { |
| + let leaf = leafHashHex(seq: p.seq, ts: p.ts, kind: p.kind, msgID: p.msg_id, blobHash: p.blob_hash, threadID: p.thread_id) |
| + let root = rootFromAuditPath(leafHex: leaf, m: p.leaf_index, n: p.tree_size, pathHex: p.audit_path) |
| + return (root != nil && root == p.root, root) |
| + } |
| + |
| + /// blobHashHex commits to a stored ciphertext string the way the server does |
| + /// (ciphertextHash = sha256 hex of the blob string), so a client sampling a |
| + /// message can match the journal's blob_hash against what it holds. |
| + public static func blobHashHex(_ blob: String) -> String { |
| + hex(Array(SHA256.hash(data: Data(blob.utf8)))) |
| } |
| /// gossipVerdict compares a peer's gossiped head against our verified head + |
diff --git a/ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift b/ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift| index 3200f38..b14e14e 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift |
| @@ -36,6 +36,7 @@ public final class UserDefaultsIntegrityStore: IntegrityStore { |
| struct IntegrityState: Codable { |
| var head: CkptHead? |
| var hist: [CkptGossip] = [] |
| + var roots: [String] = [] // verified checkpoint roots (inclusion-proof trust) |
| var alerts: [CkptAlert] = [] |
| var acked: [String] = [] |
| var pending: PendingAhead? |
| @@ -44,6 +45,7 @@ struct IntegrityState: Codable { |
| var lastPollAt: Int64? |
| var gossipSeen: Int = 0 |
| var gossipMatch: Int = 0 |
| + var sampleVerified: Int = 0 // held messages proven committed via inclusion proofs |
| struct PendingAhead: Codable { var seqTo: Int64; var chain: String; var at: Int64 } |
| } |
| @@ -54,6 +56,7 @@ public final class IntegrityMonitor: ObservableObject { |
| /// us before we call it withholding (the sweep runs every 5 minutes). |
| public static let aheadGraceMs: Int64 = 15 * 60 * 1000 |
| private static let histKeep = 64 |
| + private static let rootsKeep = 64 |
| private static let alertsKeep = 20 |
| private static let pageSize = 100 |
| @@ -106,6 +109,52 @@ public final class IntegrityMonitor: ObservableObject { |
| return v |
| } |
| + /// recordAlert lets other subsystems (e.g. key-transparency bind-MAC |
| + /// verification) file an integrity alert through the same deduped, |
| + /// banner-raising path the chain checks use. `key` distinguishes instances |
| + /// of the same kind (e.g. a user id) so re-checking the same member doesn't |
| + /// spam. Mirrors integrity.js recordAlert. |
| + public func recordAlert(kind: String, key: Int64) { |
| + addAlert(CkptAlert(kind: kind, seqTo: nil, id: key, at: now())) |
| + save() |
| + } |
| + |
| + // MARK: inclusion-proof sampling |
| + |
| + /// sampleAndVerify proves a handful of messages the client HOLDS are |
| + /// committed to the chain (docs/tamper-evidence.md client sampling). For |
| + /// each (msgID, blobHash) sample, fetch its inclusion proofs and check them |
| + /// against roots we verified via the signed chain. Sound + false-alarm-free: |
| + /// • a proof against a trusted root whose audit path doesn't recompute → |
| + /// the server forged it → "bad-proof" alert. |
| + /// • a proof against a trusted root matching our held ciphertext → that |
| + /// message is provably committed (counted). |
| + /// • no matching proof → NOT alarmed (could be too recent to checkpoint). |
| + /// Mirror of integrity.js sampleAndVerify. |
| + @discardableResult |
| + public func sampleAndVerify(_ samples: [(msgID: Int64, blobHash: String)], |
| + fetchProof: (Int64) async -> CheckpointProofResponse?) async -> (checked: Int, verified: Int) { |
| + let trusted = Set(state.roots) |
| + guard !trusted.isEmpty else { return (0, 0) } // nothing verified to check against yet |
| + var checked = 0, verified = 0 |
| + for s in samples { |
| + guard let resp = await fetchProof(s.msgID), let proofs = resp.proofs else { continue } |
| + checked += 1 |
| + for proof in proofs { |
| + guard trusted.contains(proof.root) else { continue } // a checkpoint we haven't verified |
| + let (ok, _) = Checkpoint.verifyInclusion(proof) |
| + if !ok { |
| + addAlert(CkptAlert(kind: "bad-proof", seqTo: proof.seq, id: nil, at: now())) |
| + continue |
| + } |
| + if proof.blob_hash == s.blobHash { verified += 1 } |
| + } |
| + } |
| + state.sampleVerified += verified |
| + save() |
| + return (checked, verified) |
| + } |
| + |
| // MARK: poll |
| /// Verify everything past our head. The first page re-fetches the head row |
| @@ -148,6 +197,7 @@ public final class IntegrityMonitor: ObservableObject { |
| for a in out.alerts { addAlert(a) } |
| if let h = out.head { state.head = h } |
| state.hist = Array((state.hist + out.hist).suffix(Self.histKeep)) |
| + state.roots = Array((state.roots + out.roots).suffix(Self.rootsKeep)) |
| if out.fatal { save(); return } |
| } |
| if rawLen < Self.pageSize { caughtUp = true; break } |
| @@ -189,12 +239,14 @@ public final class IntegrityMonitor: ObservableObject { |
| public var sigOk: Bool |
| public var gossipSeen: Int |
| public var gossipMatch: Int |
| + public var sampleVerified: Int |
| public var alertCount: Int |
| public var latestAlertKind: String? |
| } |
| public func report() -> Report { |
| Report(head: state.head, lastPollAt: state.lastPollAt, sigOk: state.sigOk ?? true, |
| gossipSeen: state.gossipSeen, gossipMatch: state.gossipMatch, |
| + sampleVerified: state.sampleVerified, |
| alertCount: state.alerts.count, latestAlertKind: state.alerts.last?.kind) |
| } |
| @@ -215,6 +267,10 @@ public final class IntegrityMonitor: ObservableObject { |
| return "This space's activity record failed its cryptographic check — it may have been altered." |
| case "broken-link", "bad-chain-hash", "gap", "row-drift", "key-change": |
| return "This space's activity record doesn't hold together as it should — a sign it may have been altered." |
| + case "bad-proof": |
| + return "The server couldn't prove one of your messages is in this space's activity record — a sign the record may have been altered." |
| + case "key-substitution": |
| + return "The key shown for someone you invited isn't the one they set up with. The server may have swapped in its own key to read your messages — don't share anything sensitive, and check with them on another channel." |
| default: |
| return "This space failed an integrity check." |
| } |
| @@ -239,6 +295,9 @@ public final class IntegrityMonitor: ObservableObject { |
| let heads = r.gossipSeen == 1 ? "1 peer's record" : "\(r.gossipSeen.formatted()) peers' records" |
| lines.append("Cross-checked against \(heads) (\(r.gossipMatch.formatted()) matched)") |
| } |
| + if r.sampleVerified > 0 { |
| + lines.append("Your messages proven in the record: \(r.sampleVerified.formatted())") |
| + } |
| } else { |
| lines.append("No verified checkpoints yet — the chain starts with this space's first activity.") |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift| index eeae1cd..4ff4b61 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift |
| @@ -12,6 +12,7 @@ public enum InviteCrypto { |
| public let wrapKey: GroupKey // AES-GCM from HKDF("lchat-invite-wrap") |
| public let claimSecret: String // b64url HKDF("lchat-invite-claim") — sent raw to CLAIM |
| public let claimHash: String // lowercase hex SHA-256 of the claim secret (stored on MINT) |
| + public let bindKey: String // b64url HKDF("lchat-invite-bind") — key transparency (#573) |
| } |
| /// A fresh 128-bit invite secret (crypto.js randomSecret). |
| @@ -35,7 +36,34 @@ public enum InviteCrypto { |
| return Derived(id: bits("lchat-invite-id").base64urlEncodedString(), |
| wrapKey: try GroupKey(rawKey: bits("lchat-invite-wrap")), |
| claimSecret: claimSecret, |
| - claimHash: claimHash) |
| + claimHash: claimHash, |
| + bindKey: bits("lchat-invite-bind").base64urlEncodedString()) |
| + } |
| + |
| + // Key transparency (#573, docs/tamper-evidence.md): the joiner MACs its |
| + // published public key under bindKey (derived from T) so a T-holder (the |
| + // inviter) can verify the roster key wasn't substituted. Byte-compatible |
| + // with crypto.js bindMac/verifyBindMac — HMAC-SHA256 over the DECODED |
| + // public-key bytes (not the base64url string), so a server re-encoding the |
| + // roster string can't break an honest member's verify. |
| + |
| + /// Sign a public key: bindMac(bindKey, pub) = HMAC-SHA256(decode(pub)) keyed |
| + /// by decode(bindKey). Inputs and output are base64url. Returns nil on |
| + /// malformed input. |
| + public static func bindMac(bindKeyB64: String, pubB64: String) -> String? { |
| + guard let keyData = Data(base64urlEncoded: bindKeyB64), |
| + let pubData = Data(base64urlEncoded: pubB64) else { return nil } |
| + let mac = HMAC<SHA256>.authenticationCode(for: pubData, using: SymmetricKey(data: keyData)) |
| + return Data(mac).base64urlEncodedString() |
| + } |
| + |
| + /// Verify a bind-MAC — false (never throws) on any malformed input. |
| + public static func verifyBindMac(bindKeyB64: String, pubB64: String, macB64: String) -> Bool { |
| + guard !bindKeyB64.isEmpty, !pubB64.isEmpty, !macB64.isEmpty, |
| + let keyData = Data(base64urlEncoded: bindKeyB64), |
| + let pubData = Data(base64urlEncoded: pubB64), |
| + let macData = Data(base64urlEncoded: macB64) else { return false } |
| + return HMAC<SHA256>.isValidAuthenticationCode(macData, authenticating: pubData, using: SymmetricKey(data: keyData)) |
| } |
| /// invite.js buildInviteFragment: "e1.<T>" or "e1.<T>~<encoded name>". |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index 3d83980..6335cac 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -17,8 +17,11 @@ public struct LChatAPI { |
| // MARK: Identity / roster |
| /// Registers (idempotently) this device's user public key in the roster. |
| - public func ensurePubkey(_ publicKey: String) async { |
| - await postJSON("/api/pubkey", ["token": token, "public_key": publicKey]) |
| + /// `bindMac` is the key-transparency binding (#573) — HMAC of the key under |
| + /// the invite secret T, so the inviter can verify it. Empty for add-device / |
| + /// pre-KT (the server treats "" as absent). |
| + public func ensurePubkey(_ publicKey: String, bindMac: String = "") async { |
| + await postJSON("/api/pubkey", ["token": token, "public_key": publicKey, "bind_mac": bindMac]) |
| } |
| public func roster() async -> [RosterEntry] { |
| @@ -178,6 +181,18 @@ public struct LChatAPI { |
| return try? await getJSON("/api/checkpoints", CheckpointsResponse.self, query: items) |
| } |
| + /// Merkle inclusion proofs that a message is committed to the chain |
| + /// (docs/tamper-evidence.md client sampling). Returns every committed |
| + /// journal event of msgID with its audit path, plus committed_to. nil on a |
| + /// network error (the monitor treats that as "nothing to conclude"). |
| + public func checkpointProof(msgID: Int64) async -> CheckpointProofResponse? { |
| + let items = [ |
| + URLQueryItem(name: "token", value: token), |
| + URLQueryItem(name: "msg", value: String(msgID)), |
| + ] |
| + return try? await getJSON("/api/checkpoint-proof", CheckpointProofResponse.self, query: items) |
| + } |
| + |
| /// The page of messages immediately older than `before` (ascending) — |
| /// scroll-back pagination, mirroring the web's loadOlder. |
| public func messagesBefore(thread: String, before: Int64) async -> [ServerMessage] { |
| @@ -463,25 +478,25 @@ public struct LChatAPI { |
| /// e.g. the admin has inviting turned off). |
| public func createUserInvite(labelBlob: String, id: String, wrapped: String, |
| selfWrapped: String, claimHash: String) |
| - async -> (threadID: String?, error: String?) { |
| + async -> (threadID: String?, userID: String?, error: String?) { |
| guard let url = URL(string: "api/invites", relativeTo: baseURL), |
| let data = try? JSONSerialization.data(withJSONObject: [ |
| "token": token, "label_blob": labelBlob, "id": id, |
| "wrapped": wrapped, "self_wrapped": selfWrapped, |
| "claim_hash": claimHash, |
| - ]) else { return (nil, "bad request") } |
| + ]) else { return (nil, nil, "bad request") } |
| var req = URLRequest(url: url) |
| req.httpMethod = "POST" |
| req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| req.httpBody = data |
| guard let (body, resp) = try? await session.data(for: req), |
| - let http = resp as? HTTPURLResponse else { return (nil, "network error") } |
| - struct R: Codable { var thread_id: String?; var error: String? } |
| + let http = resp as? HTTPURLResponse else { return (nil, nil, "network error") } |
| + struct R: Codable { var thread_id: String?; var user_id: String?; var error: String? } |
| let r = try? JSONDecoder().decode(R.self, from: body) |
| guard http.statusCode == 200, let tid = r?.thread_id else { |
| - return (nil, r?.error ?? "invite failed") |
| + return (nil, nil, r?.error ?? "invite failed") |
| } |
| - return (tid, nil) |
| + return (tid, r?.user_id, nil) |
| } |
| /// Publish the caller's display name — encrypted under the instance key |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 8f0dbd0..7a82cf9 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -95,6 +95,10 @@ public struct RosterEntry: Codable, Equatable { |
| public var label: String // display name; decrypted from label_blob when present |
| public var label_blob: String? // name encrypted under the instance key (server blanks `label` once set this way) |
| public let public_key: String |
| + // Key transparency (#573): HMAC of public_key under the invite secret T, so |
| + // a T-holder (the inviter) can verify the roster key wasn't substituted |
| + // (InviteCrypto.verifyBindMac). "" / nil for pre-KT or add-device members. |
| + public var bind_mac: String? |
| public var avatar: String? |
| public var is_agent: Bool? // an agent/bot helper, not a person (BOT badge in pickers) |
| public var kind: String? = nil // "ai" = LLM-backed (AI badge); anything else = deterministic bot |
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift| index 22a51af..4b9839a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| @@ -44,9 +44,30 @@ public final class NativeFeedModelCache { |
| public func pollIntegrity(for instance: SavedInstance) async { |
| guard let base = instance.baseURL else { return } |
| let api = LChatAPI(baseURL: base, token: instance.deviceToken) |
| - await integrityMonitor(for: instance).poll { after in |
| - await api.checkpoints(after: after) |
| + let monitor = integrityMonitor(for: instance) |
| + await monitor.poll { after in await api.checkpoints(after: after) } |
| + await sampleInclusion(instance: instance, api: api, monitor: monitor) |
| + } |
| + |
| + /// Prove a few messages this device can see are committed to the verified |
| + /// chain (docs/tamper-evidence.md client sampling). Native doesn't cache raw |
| + /// ciphertext, so we re-fetch a couple of recent rows (ServerMessage.blob), |
| + /// hash them the way the server did, and verify the inclusion proofs against |
| + /// roots the monitor trusts. Best-effort; a forged proof raises a bad-proof |
| + /// alert, a no-proof is never alarmed (could be too recent). |
| + private func sampleInclusion(instance: SavedInstance, api: LChatAPI, monitor: IntegrityMonitor) async { |
| + // A couple of threads, a few messages each — across many members over |
| + // time this covers the DB probabilistically at trivial per-poll cost. |
| + let threads = await api.threads().shuffled().prefix(2) |
| + var samples: [(msgID: Int64, blobHash: String)] = [] |
| + for t in threads { |
| + let msgs = await api.messages(thread: t.id, after: 0).suffix(20).shuffled().prefix(3) |
| + for m in msgs where !m.blob.isEmpty { |
| + samples.append((msgID: m.id, blobHash: Checkpoint.blobHashHex(m.blob))) |
| + } |
| } |
| + guard !samples.isEmpty else { return } |
| + await monitor.sampleAndVerify(samples) { msgID in await api.checkpointProof(msgID: msgID) } |
| } |
| // The latest decrypted roster names per instance (uid → name) — seeded |
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift| index 34f40dc..ac24b32 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| @@ -116,6 +116,7 @@ public enum SpaceClaim { |
| var keypair: (pub: String, priv: String) |
| var wrappedSelf = "" // a DM invite's thread key, re-wrapped to us |
| var suggested = "" // add-device links carry the name in the fragment |
| + var addDevice = false // add-device re-registers an existing key: no new bind-MAC |
| if let w = info.wrapped, !w.isEmpty { |
| guard let payload = try? derived.wrapKey.decryptJSON(InvitePayload.self, blob: w) else { |
| throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).") |
| @@ -123,6 +124,7 @@ public enum SpaceClaim { |
| if let priv = payload.priv, let pub = payload.pub { |
| // Add-device: sign in as the existing identity. |
| keypair = (pub, priv) |
| + addDevice = true |
| suggested = suggestedName(fromFragment: joinURL.fragment ?? "") ?? "Me" |
| } else if let tk = payload.threadKey, let raw = Data(base64urlEncoded: tk) { |
| // DM invite: fresh identity, our copy of the DM key rides with |
| @@ -155,7 +157,11 @@ public enum SpaceClaim { |
| let screenName = screenName.isEmpty ? suggested : screenName |
| let api = LChatAPI(baseURL: base, token: token) |
| - await api.ensurePubkey(keypair.pub) |
| + // Key transparency (#573): a fresh joiner MACs its key under the |
| + // T-derived bind key so the inviter can verify it wasn't substituted. |
| + // Add-device keeps its original binding (no new MAC). |
| + let bindMac = addDevice ? "" : (InviteCrypto.bindMac(bindKeyB64: derived.bindKey, pubB64: keypair.pub) ?? "") |
| + await api.ensurePubkey(keypair.pub, bindMac: bindMac) |
| var inst = SavedInstance(origin: origin, deviceToken: token, |
| screenName: screenName, |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index e9cf672..a1952a5 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -198,6 +198,7 @@ public final class ThreadStore: ObservableObject { |
| Task { await api.nudgeTier(id) } |
| } |
| roster = folded.people |
| + verifyInviteBindings() // KT: check any invitees we hold bind keys for (#573) |
| rosterLabels = folded.rosterLabels |
| nicknames = folded.nicknames |
| if let nf = nickFetch, nf.version >= nicknamesVersion { nicknamesVersion = nf.version } |
| @@ -1405,11 +1406,59 @@ public final class ThreadStore: ObservableObject { |
| wrapped: built.wrapped, selfWrapped: built.selfWrapped, |
| claimHash: built.claimHash) |
| guard r.threadID != nil else { return (nil, r.error) } |
| + // Key transparency (#573): stash the T-derived bind key against the |
| + // invitee's user id. Once they join and publish a key, verifyInviteBindings |
| + // confirms the roster key is the one THEY published, not a substitute. |
| + if let uid = r.userID, let bindKey = try? InviteCrypto.derive(t).bindKey { |
| + rememberInviteBindKey(uid: uid, bindKey: bindKey) |
| + } |
| await refresh() |
| let link = instance.origin + "/join/join#" + InviteCrypto.fragment(t, name: name) |
| return (link, nil) |
| } |
| + // ---- key transparency: verify-before-wrap (advisory, #573) ---------------- |
| + // The inviter is the one member who holds T for a joiner, so only they can |
| + // check the bind-MAC. We stash each invitee's bind key at invite time, and |
| + // once they've joined + published a key, confirm the roster key is the one |
| + // THEY published. Advisory (Paul's call): a mismatch records an integrity |
| + // alert (raising the banner), never blocks. A verified binding stays stashed |
| + // and re-checks every roster load (a later substitution at a new wrap is |
| + // caught too, not just the join window). A pre-KT member with no bind-MAC is |
| + // "unverified", not a failure. |
| + private var inviteBindsKey: String { "lchat_invite_binds_" + instance.id } |
| + private func loadInviteBinds() -> [String: String] { |
| + (UserDefaults.standard.dictionary(forKey: inviteBindsKey) as? [String: String]) ?? [:] |
| + } |
| + private func rememberInviteBindKey(uid: String, bindKey: String) { |
| + var m = loadInviteBinds() |
| + m[uid] = bindKey |
| + UserDefaults.standard.set(m, forKey: inviteBindsKey) |
| + } |
| + private func verifyInviteBindings() { |
| + var m = loadInviteBinds() |
| + guard !m.isEmpty else { return } |
| + let monitor = NativeFeedModelCache.shared.integrityMonitor(for: instance) |
| + var next: [String: String] = [:] |
| + for (uid, bindKey) in m { |
| + guard let e = roster.first(where: { $0.user_id == uid }) else { continue } // gone → drop |
| + next[uid] = bindKey // keep + re-check every load |
| + if e.public_key.isEmpty { continue } // not keyed yet |
| + guard let mac = e.bind_mac, !mac.isEmpty else { continue } // pre-KT: unverifiable, not a failure |
| + if !InviteCrypto.verifyBindMac(bindKeyB64: bindKey, pubB64: e.public_key, macB64: mac) { |
| + monitor.recordAlert(kind: "key-substitution", key: uidToAlertKey(uid)) |
| + } |
| + } |
| + if next.count != m.count { UserDefaults.standard.set(next, forKey: inviteBindsKey) } |
| + m = next |
| + } |
| + // The alert dedup discriminator is an Int64; hash the uid stably into one. |
| + private func uidToAlertKey(_ uid: String) -> Int64 { |
| + var h: UInt64 = 1469598103934665603 |
| + for b in uid.utf8 { h = (h ^ UInt64(b)) &* 1099511628211 } |
| + return Int64(bitPattern: h) |
| + } |
| + |
| /// Change your display name across the instance: encrypted under the |
| /// instance key (the web's publishMyLabel). Callers should also persist |
| /// the new name into the SavedInstance so outgoing payloads carry it. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/InclusionProofTests.swift b/ios/LChatCore/Tests/LChatCoreTests/InclusionProofTests.swift| new file mode 100644 |
| index 0000000..5468dbd |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/InclusionProofTests.swift |
| @@ -0,0 +1,177 @@ |
| +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..<n { |
| + leaves.append(Checkpoint.leafHashHex(seq: Int64(i), ts: Int64(n), kind: "k", msgID: Int64(i), blobHash: "b\(i)", threadID: "t")) |
| + } |
| + let root = merkleRootHex(leaves) |
| + for m in 0..<n { |
| + let path = auditPathHex(leaves, m) |
| + let got = Checkpoint.rootFromAuditPath(leafHex: leaves[m], m: m, n: n, pathHex: path) |
| + XCTAssertEqual(got, root, "n=\(n) m=\(m)") |
| + } |
| + } |
| + } |
| + |
| + // Local reference Merkle (RFC 6962) to cross-check rootFromAuditPath — mirrors |
| + // checkpoint.go merkleRoot/merkleAuditPath, independent of the code under test. |
| + private func nodeHex(_ l: String, _ r: String) -> 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..<k])), merkleRootHex(Array(leaves[k...]))) |
| + } |
| + private func auditPathHex(_ leaves: [String], _ m: Int) -> [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..<k]), m) + [merkleRootHex(Array(leaves[k...]))] } |
| + return auditPathHex(Array(leaves[k...]), m - k) + [merkleRootHex(Array(leaves[0..<k]))] |
| + } |
| + private func hexBytes(_ s: String) -> [UInt8] { |
| + var out = [UInt8](); var i = s.startIndex |
| + while i < s.endIndex { let j = s.index(i, offsetBy: 2); out.append(UInt8(s[i..<j], radix: 16)!); i = j } |
| + return out |
| + } |
| + |
| + // MARK: sampleAndVerify (monitor) |
| + |
| + private final class MemStore: IntegrityStore { |
| + var data: Data? |
| + func loadIntegrity() -> 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)) |
| + } |
| +} |