eleven / messenger Public

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>
Paul Campbell 4947ae069d74c6fbc474dc4fe5cc928b69e77679 parent c602b1a
9 files changed, +474 −13
  • ios/LChatCore/Sources/LChatCore/Checkpoint.swift +103 −1
  • ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift +59 −0
  • ios/LChatCore/Sources/LChatCore/InviteCrypto.swift +29 −1
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +23 −8
  • ios/LChatCore/Sources/LChatCore/Models.swift +4 −0
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +23 −2
  • ios/LChatCore/Sources/LChatCore/SpaceCreate.swift +7 −1
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +49 −0
  • ios/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))
+ }
+}