native: tamper-evidence (verify chain + gossip + banner + /integrity) for #573 (#789)
* native: tamper-evidence verification engine in LChatCore (#573, part 1 of native) The Swift twin of web/static/integrity.js — the load-bearing, vector- pinned core, fully unit-tested with `swift test` (no simulator). - Checkpoint.swift: nonisolated pure core — chainHashHex (pinned to the SAME frozen vector checkpoint_test.go + integrity.test.mjs pin, so the three platforms can't disagree about a valid chain), Ed25519 verify via CryptoKit Curve25519.Signing over the base64 payload string, verifyPage (key-change alerts but continues; broken hash/link/sig stops; malformed alerts, never throws), gossipVerdict (global boundaries → a missing peer boundary in-window is a fork). - IntegrityMonitor.swift: @MainActor twin of createIntegrity — poll with rollback detection, gossipOut/gossipIn, ahead→resolve/withheld, once-per- alert onAlert, unresolvedAlerts/acknowledge for the (PR-2) banner. Single-process @MainActor, so no cross-tab race to guard. - MessagePayload gains optional _ckpt (read + tolerated; encode omits when nil, so the event-vector contract is undisturbed — 258 core tests pass). - LChatAPI.checkpoints(after:) — the poll fetch. - CheckpointTests.swift: 15 tests (cross-platform hash vector, verify happy/tampered/garbled/rotation/broken-link/row-drift, gossip verdicts, _ckpt wire tolerance, monitor poll/rollback/fork/ack-reraise/ahead- withheld/persistence). Deliberately NO wiring yet: ChatFeedModel emit/compare, monitor ownership, poll hook, and the failure banner + native /integrity land together in part 2, tested on device — so receive-path gossip coverage ships complete (covering every path at once), never partial (a partial cover would let a host route forged messages down an unchecked path). Native change: no changelog until the release is minted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: wire tamper-evidence into the apps — emit, compare, banner, /integrity (#573) Part 2 (the app wiring), completing native parity with the web: - ChatFeedModel emits _ckpt on every send (send + sendViaREST) and compares incoming gossip on EVERY decrypt path — live (foldDecrypted), live reactions (applyReactionEvent), and history (foldPageDetached messages + reactions) — so a host can't route forged messages down an unchecked path. - NativeFeedModelCache owns one IntegrityMonitor per instance, injects it into every model, and polls the chain (LChatAPI.checkpoints) on connect + foreground (iOS scenePhase, Mac .task). - IntegrityBanner (shared LChatCore SwiftUI): amber failure band shown only on an unresolved alert; What's this? runs /integrity, Dismiss acknowledges. Mounted app-wide per instance (iOS ThreadListView, Mac NativeFeedView). - SlashCommand.integrity + a private report alert on both platforms; shared reportLines()/alertMessage() so the copy is identical (pluralised + localised counts per CLAUDE.md). - 2 more LChatCore tests (report pluralisation, alert copy) — swift test 258 pass; iOS + Mac both BUILD SUCCEEDED. Needs on-device verification; no changelog until the native release is minted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * native: surface /integrity in the slash typeahead (was handler-only) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+953
−11
ios/LChat/Views/NativeChatScreen.swift+15 −0ios/LChat/Views/ThreadListView.swift+30 −3ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+26 −0ios/LChatCore/Sources/LChatCore/Checkpoint.swift+197 −0ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift+6 −1ios/LChatCore/Sources/LChatCore/IntegrityBanner.swift+60 −0ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift+272 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+13 −0ios/LChatCore/Sources/LChatCore/Models.swift+8 −0ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+30 −0ios/LChatCore/Sources/LChatCore/SlashCommands.swift+3 −0ios/LChatCore/Tests/LChatCoreTests/CheckpointTests.swift+258 −0ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift+8 −7ios/LChatMac/NativeFeedView.swift+27 −0
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 34b035d..1ee082b 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -436,6 +436,15 @@ struct NativeChatScreen: View { |
| .sheet(isPresented: $showThemeSettings) { |
| ServerSettingsSheet(store: store) |
| } |
| + // /integrity read-out (tamper evidence). A private, in-place answer, |
| + // like the web command's notice. |
| + .alert("Integrity check", isPresented: .init( |
| + get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } }) |
| + ) { |
| + Button("OK", role: .cancel) { integrityReport = nil } |
| + } message: { |
| + Text(integrityReport ?? "") |
| + } |
| } |
| // The viewers own their whole choreography — the photo zooms out of (and |
| @@ -1283,6 +1292,11 @@ struct NativeChatScreen: View { |
| draft = "" |
| showThemeSettings = true |
| return |
| + case .integrity: |
| + draft = "" |
| + integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") } |
| + ?? "Integrity monitoring isn't active for this space." |
| + return |
| case .help, .sendAsMessage: |
| break |
| } |
| @@ -1329,6 +1343,7 @@ struct NativeChatScreen: View { |
| } |
| @State private var showThemeSettings = false |
| + @State private var integrityReport: String? // /integrity read-out (tamper evidence) |
| private func startReply(_ m: ChatFeedModel.Message) { |
| editingID = nil |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index a92ce08..bf54de1 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -94,7 +94,16 @@ struct ThreadListView: View { |
| // quiet a cue when a chat silently vanished from the list. Occupies |
| // safe-area space (never floats over rows), with a one-tap way back. |
| .safeAreaInset(edge: .top, spacing: 0) { |
| - if filtering { filterChip } |
| + VStack(spacing: 0) { |
| + // Tamper-evidence failure banner (docs/tamper-evidence.md) — shows |
| + // only when this space fails an integrity check; "What's this?" |
| + // runs /integrity into the report alert below. |
| + IntegrityBanner(monitor: NativeFeedModelCache.shared.integrityMonitor(for: store.instance)) { |
| + integrityReport = NativeFeedModelCache.shared |
| + .integrityMonitor(for: store.instance).reportLines().joined(separator: "\n") |
| + } |
| + if filtering { filterChip } |
| + } |
| } |
| .searchable(text: $search, prompt: "Search chats") |
| // A fresh member lands straight in Eleven's welcome conversation (the |
| @@ -227,14 +236,32 @@ struct ThreadListView: View { |
| } |
| } |
| .onChange(of: scenePhase) { _, phase in |
| - if phase == .active { shareFiles = ShareInbox.pending() } |
| + if phase == .active { |
| + shareFiles = ShareInbox.pending() |
| + // Tamper evidence: re-verify the checkpoint chain on foreground |
| + // (the native twin of the web's poll on ws.onopen). |
| + let inst = store.instance |
| + Task { await NativeFeedModelCache.shared.pollIntegrity(for: inst) } |
| + } |
| } |
| .onAppear { shareFiles = ShareInbox.pending() } |
| // Keyed on the instance: when the store swaps in place (switching server), |
| // SwiftUI reuses this view, so an un-keyed .task would not re-fire and the new |
| // server's chats would never load. |
| - .task(id: store.instance.id) { await load() } |
| + .task(id: store.instance.id) { |
| + await NativeFeedModelCache.shared.pollIntegrity(for: store.instance) |
| + await load() |
| + } |
| + // /integrity read-out, shown from the banner's "What's this?". |
| + .alert("Integrity check", isPresented: .init( |
| + get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } }) |
| + ) { |
| + Button("OK", role: .cancel) { integrityReport = nil } |
| + } message: { |
| + Text(integrityReport ?? "") |
| + } |
| } |
| + @State private var integrityReport: String? |
| // ---- search results (conversations + message full-text) ----------------- |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index cd8c972..13100e9 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -128,6 +128,20 @@ public final class ChatFeedModel: ObservableObject { |
| private let key: GroupKey? |
| private var socket: ChatSocket? |
| + // Tamper evidence (docs/tamper-evidence.md, #573): the per-instance monitor, |
| + // set by the app after creating the model. Outgoing messages carry its |
| + // verified head as `_ckpt`; incoming `_ckpt` is compared to detect a server |
| + // that showed members different histories. nil = feature inert. |
| + public var integrity: IntegrityMonitor? |
| + |
| + /// Compare a decrypted payload's gossip (if any) against our verified chain |
| + /// head. Called on EVERY inbound decrypt path — live, history, reactions — |
| + /// because the server chooses which path a message arrives by. `own` |
| + /// messages still compare (per-device split view) but don't count as peers. |
| + private func noteGossip(_ payload: MessagePayload?, own: Bool) { |
| + if let g = payload?._ckpt { integrity?.gossipIn(g, countPeer: !own) } |
| + } |
| + |
| // ---- group apps (the web's groupapps registry, folded natively) --------- |
| /// The mini-apps added to this GROUP chat (⋮ → Add apps on the web), |
| @@ -461,6 +475,7 @@ public final class ChatFeedModel: ObservableObject { |
| var action: Bool? // /me-style centred line, not a bubble |
| var attachments: [Attachment]? |
| var replyTo: ReplyRef? |
| + var _ckpt: CkptGossip? // tamper-evidence gossip (nil = omitted) |
| } |
| // A typed mini-app event (todo.add, remind.add, …) — the web's ctx.emit |
| @@ -749,6 +764,7 @@ public final class ChatFeedModel: ObservableObject { |
| if !myUserId.isEmpty { payload.uid = myUserId } |
| if !attachments.isEmpty { payload.attachments = attachments } |
| payload.replyTo = replyTo |
| + payload._ckpt = integrity?.gossipOut() // tamper-evidence gossip (inside the ciphertext) |
| // The hub stores + broadcasts to everyone including us; the echo frame |
| // folds the message into the list (same as the web — no optimistic row). |
| encryptAndSend(payload, via: socket) |
| @@ -765,6 +781,7 @@ public final class ChatFeedModel: ObservableObject { |
| ts: Int64(Date().timeIntervalSince1970 * 1000)) |
| if !myUserId.isEmpty { payload.uid = myUserId } |
| if !attachments.isEmpty { payload.attachments = attachments } |
| + payload._ckpt = integrity?.gossipOut() |
| let sendable = payload |
| guard let blob = await Task.detached(priority: .userInitiated, |
| operation: { try? key.encryptJSON(sendable) }).value, |
| @@ -963,6 +980,13 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| return (out, toStore, events, reacts) |
| }.value |
| + // Tamper-evidence (history): compare gossip from every message + reaction |
| + // this page decrypted — the server can route forged messages via |
| + // catch-up, so history must count too, not just live frames. |
| + if integrity != nil { |
| + for s in stored { noteGossip(s.payload, own: s.payload.uid == myUserId) } |
| + for p in reactionRows { noteGossip(p, own: p.uid == myUserId) } |
| + } |
| if !appEvents.isEmpty { |
| for (id, e) in appEvents { groupAppsEvents[id] = e } |
| recomputeGroupApps() |
| @@ -1123,6 +1147,7 @@ public final class ChatFeedModel: ObservableObject { |
| /// target message, and mirror the derived tally into the local store so a |
| /// reopened chat renders it from disk. |
| private func applyReactionEvent(_ payload: MessagePayload) { |
| + noteGossip(payload, own: payload.uid == myUserId) // tamper-evidence (live reactions) |
| guard let sid = payload.targetSid, |
| let i = messages.firstIndex(where: { $0.id == sid }) else { return } |
| var m = messages[i] |
| @@ -1247,6 +1272,7 @@ public final class ChatFeedModel: ObservableObject { |
| // store. |
| private func foldDecrypted(id: Int64, at: Int64, payload: MessagePayload?, |
| edited: Bool, reactions: [Reaction]) { |
| + noteGossip(payload, own: payload?.uid == myUserId) // tamper-evidence (live) |
| var list = messages |
| fold(id: id, at: at, payload: payload, |
| edited: edited, reactions: reactions, into: &list) |
diff --git a/ios/LChatCore/Sources/LChatCore/Checkpoint.swift b/ios/LChatCore/Sources/LChatCore/Checkpoint.swift| new file mode 100644 |
| index 0000000..1be020e |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/Checkpoint.swift |
| @@ -0,0 +1,197 @@ |
| +// Checkpoint.swift — native tamper-evidence: verify the instance's signed |
| +// checkpoint chain and gossip verified heads between members inside E2EE |
| +// traffic (docs/tamper-evidence.md, #573 slices 1+2). The Swift twin of |
| +// web/static/integrity.js — the pure verification core is nonisolated static |
| +// so it can be unit-tested with `swift test` and pinned to the SAME wire |
| +// vectors the Go and web sides pin (CheckpointTests + checkpoint_test.go + |
| +// integrity.test.mjs must agree, or the platforms disagree about what a valid |
| +// chain is). |
| +// |
| +// Ed25519 signature verification uses CryptoKit's Curve25519.Signing, over the |
| +// base64 payload STRING bytes exactly as the web does (verify-first, decode |
| +// after). The chain of hashes is what holds history together; the signature is |
| +// attribution. A verification failure must always END as a recorded alert, |
| +// never a thrown error — a monitor that dies quietly on one garbled row is an |
| +// alarm-suppression vector. |
| + |
| +import Foundation |
| +import CryptoKit |
| + |
| +// MARK: - Wire types (match /api/checkpoints and checkpoint.go) |
| + |
| +/// One checkpoint row as served by GET /api/checkpoints. |
| +public struct CheckpointRow: Codable, Sendable, Equatable { |
| + public let id: Int64 |
| + public let seq_from: Int64 |
| + public let seq_to: Int64 |
| + public let root: String |
| + public let chain: String |
| + public let ts: Int64 |
| + public let signed: String // JSON: {payload: base64(JSON CkptPayload), sig: base64} |
| +} |
| + |
| +public struct CheckpointsResponse: Codable, Sendable { |
| + public let checkpoints: [CheckpointRow]? |
| + public let public_key: String? |
| +} |
| + |
| +/// The signed wrapper inside `CheckpointRow.signed` (release.go shape). |
| +private struct SignedWrapper: Codable { let payload: String; let sig: String } |
| + |
| +/// The decoded payload the signature covers. |
| +private struct CkptPayload: Codable { |
| + let seq_from: Int64 |
| + let seq_to: Int64 |
| + let root: String |
| + let prev_chain: String |
| + let chain: String |
| + let ts: Int64 |
| + let pub: String // base64 ed25519 public key that signed this checkpoint |
| +} |
| + |
| +/// A member's gossiped head: `[seqTo, chain]`, encoded as a heterogeneous JSON |
| +/// array to match the web's `_ckpt` exactly. Rides inside the encrypted message |
| +/// payload so the server can neither read nor selectively strip it. |
| +public struct CkptGossip: Codable, Sendable, Equatable { |
| + public let seqTo: Int64 |
| + public let chain: String |
| + public init(seqTo: Int64, chain: String) { self.seqTo = seqTo; self.chain = chain } |
| + public init(from decoder: Decoder) throws { |
| + var c = try decoder.unkeyedContainer() |
| + seqTo = try c.decode(Int64.self) |
| + chain = try c.decode(String.self) |
| + } |
| + public func encode(to encoder: Encoder) throws { |
| + var c = encoder.unkeyedContainer() |
| + try c.encode(seqTo) |
| + try c.encode(chain) |
| + } |
| +} |
| + |
| +/// The verified head of the chain this device has confirmed. |
| +public struct CkptHead: Codable, Sendable, Equatable { |
| + public var id: Int64 |
| + public var seqTo: Int64 |
| + public var chain: String |
| + public var pub: String |
| + public var gossip: CkptGossip { CkptGossip(seqTo: seqTo, chain: chain) } |
| +} |
| + |
| +/// An integrity alert. `kind` strings match the web exactly. |
| +public struct CkptAlert: Codable, Sendable, Equatable { |
| + public var kind: String |
| + public var seqTo: Int64? |
| + public var id: Int64? |
| + public var at: Int64 |
| + /// Investigable but not fatal (a key change) — the walk continues past it. |
| + public var keep: Bool? |
| + var key: String { "\(kind):\(seqTo.map(String.init) ?? id.map(String.init) ?? "")" } |
| +} |
| + |
| +/// The result of verifying one served page of checkpoints. |
| +public struct CkptPageResult: Sendable { |
| + public var head: CkptHead? |
| + public var hist: [CkptGossip] // verified (seqTo, chain) boundary pairs |
| + public var alerts: [CkptAlert] |
| + public var fatal: Bool // a non-`keep` alert stopped the walk |
| +} |
| + |
| +// MARK: - Pure verification core (nonisolated, vector-pinned) |
| + |
| +public enum Checkpoint { |
| + static let chainDomain = "lchat-ckpt-chain\n" |
| + |
| + /// chainHashHex mirrors checkpoint.go's chainHash and integrity.js's |
| + /// chainHashHex — a checkpoint's commitment to its predecessor. `prev` is |
| + /// "" for the genesis checkpoint. This is wire format: it MUST match the Go |
| + /// and web sides byte for byte (pinned by CheckpointTests). |
| + public static func chainHashHex(prev: String, root: String, seqTo: Int64) -> String { |
| + let s = chainDomain + prev + "\n" + root + "\n" + String(seqTo) |
| + let digest = SHA256.hash(data: Data(s.utf8)) |
| + return digest.map { String(format: "%02x", $0) }.joined() |
| + } |
| + |
| + /// ed25519Verify checks one signature over the base64 payload STRING bytes, |
| + /// returning false (never throwing) on any malformed input — a garbled row |
| + /// is a recorded failure, not a crash. |
| + public static func ed25519Verify(pubBase64: String, payload: String, sigBase64: String) -> Bool { |
| + guard let pubData = Data(base64Encoded: pubBase64), |
| + let sigData = Data(base64Encoded: sigBase64), |
| + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: pubData) |
| + else { return false } |
| + return key.isValidSignature(sigData, for: Data(payload.utf8)) |
| + } |
| + |
| + /// verifyPage walks one page of served checkpoints, extending `prior` (the |
| + /// last verified head, or nil on first sync). Each payload names its own |
| + /// signing key, so a mid-chain key change alerts (investigable) but the |
| + /// walk continues; broken hashes/links/signatures stop it. A faithful port |
| + /// of integrity.js verifyCheckpointPage. |
| + public static func verifyPage(_ rows: [CheckpointRow], prior: CkptHead?, checkSigs: Bool) -> CkptPageResult { |
| + var alerts: [CkptAlert] = [] |
| + var hist: [CkptGossip] = [] |
| + var head = prior |
| + var pub = prior?.pub |
| + let now = Int64(Date().timeIntervalSince1970) |
| + |
| + for cp in rows { |
| + // Parse: signed wrapper → base64 payload → CkptPayload. Any failure |
| + // is a "malformed" alert, never a throw. |
| + guard let signedData = cp.signed.data(using: .utf8), |
| + let wrapper = try? JSONDecoder().decode(SignedWrapper.self, from: signedData), |
| + let payloadData = Data(base64Encoded: wrapper.payload), |
| + let payload = try? JSONDecoder().decode(CkptPayload.self, from: payloadData), |
| + !payload.pub.isEmpty |
| + else { |
| + alerts.append(CkptAlert(kind: "malformed", seqTo: nil, id: cp.id, at: now)) |
| + break |
| + } |
| + if checkSigs && !ed25519Verify(pubBase64: payload.pub, payload: wrapper.payload, sigBase64: wrapper.sig) { |
| + alerts.append(CkptAlert(kind: "bad-signature", seqTo: nil, id: cp.id, at: now)) |
| + break |
| + } |
| + if chainHashHex(prev: payload.prev_chain, root: payload.root, seqTo: payload.seq_to) != payload.chain { |
| + alerts.append(CkptAlert(kind: "bad-chain-hash", seqTo: nil, id: cp.id, at: now)) |
| + break |
| + } |
| + if payload.prev_chain != (head?.chain ?? "") { |
| + alerts.append(CkptAlert(kind: "broken-link", seqTo: payload.seq_to, id: cp.id, at: now)) |
| + break |
| + } |
| + if let h = head, payload.seq_from != h.seqTo + 1 { |
| + alerts.append(CkptAlert(kind: "gap", seqTo: payload.seq_to, id: cp.id, at: now)) |
| + break |
| + } |
| + if cp.seq_to != payload.seq_to || cp.chain != payload.chain { |
| + alerts.append(CkptAlert(kind: "row-drift", seqTo: nil, id: cp.id, at: now)) |
| + break |
| + } |
| + if let p = pub, payload.pub != p { |
| + // Legitimate-but-investigable (restore from a pre-key backup, |
| + // deliberate rotation) — alert once, keep verifying. |
| + alerts.append(CkptAlert(kind: "key-change", seqTo: payload.seq_to, id: cp.id, at: now, keep: true)) |
| + } |
| + 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)) |
| + } |
| + let fatal = alerts.contains { $0.keep != true } |
| + return CkptPageResult(head: head, hist: hist, alerts: alerts, fatal: fatal) |
| + } |
| + |
| + /// gossipVerdict compares a peer's gossiped head against our verified head + |
| + /// bounded boundary history. Checkpoint boundaries are global (one |
| + /// checkpoints table per instance), so a peer boundary inside our verified |
| + /// window that we don't have at all is a fork. Returns: "match" | "fork" | |
| + /// "ahead" | "unknown-old" | "no-head". Port of integrity.js gossipVerdict. |
| + public static func gossipVerdict(head: CkptHead?, hist: [CkptGossip], gossip: CkptGossip) -> String { |
| + guard let head else { return "no-head" } |
| + let s = gossip.seqTo |
| + if s > head.seqTo { return "ahead" } |
| + if s == head.seqTo { return gossip.chain == head.chain ? "match" : "fork" } |
| + let floor = hist.first?.seqTo ?? head.seqTo |
| + if s < floor { return "unknown-old" } |
| + guard let ours = hist.first(where: { $0.seqTo == s }) else { return "fork" } |
| + return ours.chain == gossip.chain ? "match" : "fork" |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift| index 627cb5a..213f457 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift |
| @@ -105,7 +105,7 @@ public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry], |
| // the 8-item cap can only ever hide the TAIL of the bot list (the |
| // web's cap victim is /profile too), never /me, which the previous |
| // append-last ordering silently dropped in every botful thread. |
| - let floor = WebPanelCommands.catalog + [meCommand] |
| + let floor = WebPanelCommands.catalog + [meCommand, integrityCommand] |
| + botCommands.filter { hasBot || clientAnswered.contains($0.name) } |
| // Deduplicate by name (case-insensitive) to avoid showing the same command twice |
| var seen = Set<String>() |
| @@ -160,6 +160,11 @@ let clientAnswered: Set<String> = ["/theme", "/themes"] |
| // "/" typeahead surfaces it everywhere, like the web's commandCatalog does. |
| let meCommand = (name: "/me", desc: "post an action line, e.g. /me waves") |
| +// /integrity is client-answered (SlashCommands.swift → the private report), |
| +// per-instance, so it's offered in every thread like /me — the native twin of |
| +// the web's registerFloorCommand("/integrity") entry (shell.js). |
| +let integrityCommand = (name: "/integrity", desc: "check this space's tamper-evidence (only you see it)") |
| + |
| // ---- emoji (emoji.js) ----------------------------------------------------- |
| /// emoji.js emojiMatches: prefix hits first, then substring; shorter names win |
diff --git a/ios/LChatCore/Sources/LChatCore/IntegrityBanner.swift b/ios/LChatCore/Sources/LChatCore/IntegrityBanner.swift| new file mode 100644 |
| index 0000000..62ac2d9 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/IntegrityBanner.swift |
| @@ -0,0 +1,60 @@ |
| +import SwiftUI |
| + |
| +// Tamper-evidence failure banner (docs/tamper-evidence.md, #573): the native |
| +// twin of the web's #integrity-banner. Shown ONLY when this space fails an |
| +// integrity check (a fork/rollback/withheld/bad-signature the E2E rule is meant |
| +// to make un-hideable) — never for "all clear" (a pulled /integrity self-check |
| +// covers that). An inline band occupying layout, never a floating overlay; |
| +// amber with a ⚠️ so it's never colour-alone. `onDetails` runs /integrity so |
| +// the full read-out shows in place. |
| +public struct IntegrityBanner: View { |
| + @ObservedObject var monitor: IntegrityMonitor |
| + let onDetails: () -> Void |
| + |
| + public init(monitor: IntegrityMonitor, onDetails: @escaping () -> Void) { |
| + self.monitor = monitor |
| + self.onDetails = onDetails |
| + } |
| + |
| + private var title: String { |
| + let n = monitor.unresolved.count |
| + return n > 1 ? "Integrity check failed (\(n.formatted()) alerts)" : "Integrity check failed" |
| + } |
| + |
| + private var message: String { |
| + guard let latest = monitor.unresolved.last else { return "" } |
| + return IntegrityMonitor.alertMessage(latest.kind) |
| + + " Keep this device — its records are the evidence — and contact your space's admin." |
| + } |
| + |
| + public var body: some View { |
| + if !monitor.unresolved.isEmpty { |
| + HStack(alignment: .top, spacing: 10) { |
| + Image(systemName: "exclamationmark.triangle.fill") |
| + .foregroundStyle(.white) |
| + .accessibilityHidden(true) |
| + VStack(alignment: .leading, spacing: 4) { |
| + Text(title).font(.subheadline).bold() |
| + Text(message).font(.footnote) |
| + HStack(spacing: 8) { |
| + Button("What's this?") { onDetails() } |
| + .buttonStyle(.borderedProminent) |
| + Button("Dismiss") { monitor.acknowledge() } |
| + .buttonStyle(.bordered) |
| + } |
| + .controlSize(.small) |
| + .padding(.top, 2) |
| + } |
| + Spacer(minLength: 0) |
| + } |
| + .foregroundStyle(.white) |
| + .tint(.white) |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 10) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .background(Color(red: 0.72, green: 0.42, blue: 0.05)) // amber, holds in light + dark |
| + .accessibilityElement(children: .contain) |
| + .accessibilityLabel("Integrity check failed. \(message)") |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift b/ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift| new file mode 100644 |
| index 0000000..3200f38 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift |
| @@ -0,0 +1,272 @@ |
| +// IntegrityMonitor.swift — the stateful half of native tamper-evidence, the |
| +// twin of integrity.js's createIntegrity. Polls GET /api/checkpoints, verifies |
| +// each new checkpoint, remembers the verified head, and compares gossiped peer |
| +// heads to detect forks/rollback/withholding. @MainActor + single-process, so |
| +// (unlike the web's multi-tab localStorage) there is no cross-writer race to |
| +// guard — the feed and the poll timer are the same actor. |
| +// |
| +// Detection only in this slice: alerts are recorded and published; how they |
| +// surface (the failure banner, /integrity) is the UI layer. `onAlert` fires |
| +// once per genuinely-new alert so a persistent condition doesn't re-nag. |
| + |
| +import Foundation |
| + |
| +/// Where the monitor persists its state. UserDefaults-backed in the app, an |
| +/// in-memory fake in tests. The verified head is not secret — it's a |
| +/// commitment the server already published — so plain per-instance storage is |
| +/// fine (no Keychain needed). |
| +public protocol IntegrityStore: AnyObject { |
| + func loadIntegrity() -> Data? |
| + func saveIntegrity(_ data: Data) |
| +} |
| + |
| +/// UserDefaults-backed store, one key per instance host. |
| +public final class UserDefaultsIntegrityStore: IntegrityStore { |
| + private let defaults: UserDefaults |
| + private let key: String |
| + public init(host: String, defaults: UserDefaults = .standard) { |
| + self.defaults = defaults |
| + self.key = "lchat_ckpt_" + host |
| + } |
| + public func loadIntegrity() -> Data? { defaults.data(forKey: key) } |
| + public func saveIntegrity(_ data: Data) { defaults.set(data, forKey: key) } |
| +} |
| + |
| +/// Persisted monitor state (mirrors integrity.js's lchat_ckpt blob). |
| +struct IntegrityState: Codable { |
| + var head: CkptHead? |
| + var hist: [CkptGossip] = [] |
| + var alerts: [CkptAlert] = [] |
| + var acked: [String] = [] |
| + var pending: PendingAhead? |
| + var sigOk: Bool? |
| + var pub: String? |
| + var lastPollAt: Int64? |
| + var gossipSeen: Int = 0 |
| + var gossipMatch: Int = 0 |
| + |
| + struct PendingAhead: Codable { var seqTo: Int64; var chain: String; var at: Int64 } |
| +} |
| + |
| +@MainActor |
| +public final class IntegrityMonitor: ObservableObject { |
| + /// How long a gossiped head may stay ahead of everything the server shows |
| + /// 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 alertsKeep = 20 |
| + private static let pageSize = 100 |
| + |
| + private let store: IntegrityStore |
| + private let now: () -> Int64 |
| + /// Fires once per genuinely-new alert (raises the failure banner). |
| + public var onAlert: ((CkptAlert) -> Void)? |
| + |
| + /// Published so SwiftUI (the banner) reacts to failures without polling. |
| + @Published public private(set) var unresolved: [CkptAlert] = [] |
| + |
| + private var state: IntegrityState |
| + |
| + public init(store: IntegrityStore, now: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }) { |
| + self.store = store |
| + self.now = now |
| + if let data = store.loadIntegrity(), let s = try? JSONDecoder().decode(IntegrityState.self, from: data) { |
| + state = s |
| + } else { |
| + state = IntegrityState() |
| + } |
| + recomputeUnresolved() |
| + } |
| + |
| + // MARK: emit / receive gossip |
| + |
| + /// The `_ckpt` value for an outgoing payload (nil before the first verified |
| + /// head). Injected inside the encrypted message so the server can't strip it. |
| + public func gossipOut() -> CkptGossip? { state.head?.gossip } |
| + |
| + /// Fold a peer's `_ckpt` from a decrypted incoming payload — live AND |
| + /// history (the server chooses which path a message arrives by, so both |
| + /// must count). `countPeer` guards the stat: own echoes and history |
| + /// refolds compare (evidence is evidence) but don't inflate the peer count. |
| + @discardableResult |
| + public func gossipIn(_ gossip: CkptGossip, countPeer: Bool = true) -> String { |
| + let v = Checkpoint.gossipVerdict(head: state.head, hist: state.hist, gossip: gossip) |
| + if countPeer && (v == "match" || v == "fork" || v == "ahead") { |
| + state.gossipSeen += 1 |
| + if v == "match" { state.gossipMatch += 1 } |
| + } |
| + if v == "fork" { addAlert(CkptAlert(kind: "gossip-fork", seqTo: gossip.seqTo, id: nil, at: now())) } |
| + if v == "ahead" { |
| + if state.pending == nil || gossip.seqTo > state.pending!.seqTo { |
| + let at = state.pending?.at ?? now() |
| + state.pending = .init(seqTo: gossip.seqTo, chain: gossip.chain, at: at) |
| + } |
| + } |
| + save() |
| + return v |
| + } |
| + |
| + // MARK: poll |
| + |
| + /// Verify everything past our head. The first page re-fetches the head row |
| + /// itself (after = head.id - 1) so a served chain that lost our verified |
| + /// head — a rollback — is caught, not mistaken for silence. Network |
| + /// failures are silent (next poll retries); verification failures alert and |
| + /// stop; malformed rows alert, never throw. `fetchPage` returns the parsed |
| + /// /api/checkpoints body (nil on a network error). |
| + public func poll(_ fetchPage: (Int64) async -> CheckpointsResponse?) async { |
| + if state.sigOk == nil { state.sigOk = true } // CryptoKit always has Ed25519 |
| + var first = true |
| + var caughtUp = false |
| + var pages = 0 |
| + while pages < 50 { |
| + pages += 1 |
| + let after: Int64 = state.head.map { $0.id - (first ? 1 : 0) } ?? 0 |
| + guard let resp = await fetchPage(after), let rowsAll = resp.checkpoints else { |
| + save(); return // offline / bad response — nothing to conclude |
| + } |
| + let rawLen = rowsAll.count |
| + var rows = rowsAll |
| + if let head = state.head, first { |
| + guard let mine = rows.first(where: { $0.id == head.id }), |
| + mine.seq_to == head.seqTo, mine.chain == head.chain else { |
| + addAlert(CkptAlert(kind: "rollback", seqTo: head.seqTo, id: head.id, at: now())) |
| + save(); return |
| + } |
| + rows = rows.filter { $0.id > head.id } |
| + } |
| + first = false |
| + if let pk = resp.public_key, !pk.isEmpty { |
| + if let have = state.pub, have != pk { |
| + addAlert(CkptAlert(kind: "key-change", seqTo: nil, id: 0, at: now())) |
| + save(); return |
| + } |
| + if state.pub == nil { state.pub = pk } |
| + } |
| + if !rows.isEmpty { |
| + let out = Checkpoint.verifyPage(rows, prior: state.head, checkSigs: state.sigOk ?? true) |
| + 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)) |
| + if out.fatal { save(); return } |
| + } |
| + if rawLen < Self.pageSize { caughtUp = true; break } |
| + } |
| + // Resolve a pending "ahead" gossip: once our verified head reaches its |
| + // seq, matching boundary → clean, mismatched/missing → fork. Past the |
| + // grace window unresolved (and caught up) → the server is withholding. |
| + if let p = state.pending, let head = state.head, head.seqTo >= p.seqTo { |
| + if Checkpoint.gossipVerdict(head: head, hist: state.hist, gossip: CkptGossip(seqTo: p.seqTo, chain: p.chain)) == "fork" { |
| + addAlert(CkptAlert(kind: "gossip-fork", seqTo: p.seqTo, id: nil, at: now())) |
| + } |
| + state.pending = nil |
| + } else if let p = state.pending, caughtUp, now() - p.at > Self.aheadGraceMs { |
| + addAlert(CkptAlert(kind: "withheld", seqTo: p.seqTo, id: nil, at: now())) |
| + state.pending = nil |
| + } |
| + state.lastPollAt = now() |
| + save() |
| + } |
| + |
| + // MARK: banner / report accessors |
| + |
| + /// Alerts the member hasn't dismissed — what the failure banner shows. |
| + public func unresolvedAlerts() -> [CkptAlert] { |
| + state.alerts.filter { a in !state.acked.contains(a.key) } |
| + } |
| + |
| + /// Dismiss every current alert from the banner. A later, DIFFERENT alert |
| + /// (new kind or seq) re-raises; the record itself is kept for /integrity. |
| + public func acknowledge() { |
| + state.acked = Array(Set(state.acked + state.alerts.map { $0.key })).suffix(Self.alertsKeep * 2).map { $0 } |
| + save() |
| + } |
| + |
| + /// Read-only snapshot for the /integrity report. |
| + public struct Report: Sendable { |
| + public var head: CkptHead? |
| + public var lastPollAt: Int64? |
| + public var sigOk: Bool |
| + public var gossipSeen: Int |
| + public var gossipMatch: 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, |
| + alertCount: state.alerts.count, latestAlertKind: state.alerts.last?.kind) |
| + } |
| + |
| + // MARK: shared copy (identical on iOS + Mac) |
| + |
| + /// Plain-language line for an alert kind — members, not developers. Every |
| + /// kind is a server misbehaviour the E2E rule is meant to make un-hideable. |
| + /// Mirrors the web's integrityAlertMessage. |
| + nonisolated public static func alertMessage(_ kind: String) -> String { |
| + switch kind { |
| + case "gossip-fork": |
| + return "Your record of this space's history disagrees with another member's. A trustworthy server shows everyone the same history — this one may not have." |
| + case "rollback": |
| + return "This space's server served an older version of its history than your device had already verified." |
| + case "withheld": |
| + return "Another member has verified activity in this space that this space's server won't show you." |
| + case "bad-signature", "malformed": |
| + 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." |
| + default: |
| + return "This space failed an integrity check." |
| + } |
| + } |
| + |
| + /// The /integrity read-out, one string per line. Pluralised + localised |
| + /// counts (CLAUDE.md), matching the web command's private reply. |
| + public func reportLines() -> [String] { |
| + let r = report() |
| + var lines: [String] = [] |
| + if let head = r.head { |
| + lines.append("Verified up to entry \(head.seqTo.formatted()), chain \(head.chain.prefix(12))…") |
| + if let ms = r.lastPollAt { |
| + let when = Date(timeIntervalSince1970: Double(ms) / 1000) |
| + .formatted(date: .abbreviated, time: .shortened) |
| + lines.append("Last checked: \(when)") |
| + } |
| + lines.append(r.sigOk |
| + ? "Signatures: verified (Ed25519)" |
| + : "Signatures: not checked on this device; the hash chain is still verified") |
| + if r.gossipSeen > 0 { |
| + let heads = r.gossipSeen == 1 ? "1 peer's record" : "\(r.gossipSeen.formatted()) peers' records" |
| + lines.append("Cross-checked against \(heads) (\(r.gossipMatch.formatted()) matched)") |
| + } |
| + } else { |
| + lines.append("No verified checkpoints yet — the chain starts with this space's first activity.") |
| + } |
| + let unresolved = unresolvedAlerts() |
| + if unresolved.isEmpty { |
| + lines.append("No integrity alerts.") |
| + } else { |
| + let n = unresolved.count |
| + let noun = n == 1 ? "1 integrity alert" : "\(n.formatted()) integrity alerts" |
| + lines.append("⚠️ \(noun) — latest: \(unresolved.last!.kind). Keep this device — its records are the evidence — and contact your space's admin.") |
| + } |
| + return lines |
| + } |
| + |
| + // MARK: internals |
| + |
| + private func addAlert(_ a: CkptAlert) { |
| + guard !state.alerts.contains(where: { $0.kind == a.kind && $0.seqTo == a.seqTo && $0.id == a.id }) else { return } |
| + state.alerts = Array(state.alerts.suffix(Self.alertsKeep - 1)) + [a] |
| + onAlert?(a) |
| + } |
| + |
| + private func save() { |
| + recomputeUnresolved() |
| + if let data = try? JSONEncoder().encode(state) { store.saveIntegrity(data) } |
| + } |
| + |
| + private func recomputeUnresolved() { |
| + unresolved = state.alerts.filter { a in !state.acked.contains(a.key) } |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index dbd62d8..56ccfb6 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -165,6 +165,19 @@ public struct LChatAPI { |
| return (try? await getJSON("/api/messages", Resp.self, query: items))?.messages ?? [] |
| } |
| + /// The instance's signed checkpoint chain past `after` (a checkpoint row id), |
| + /// plus its current checkpoint public key — the tamper-evidence poll |
| + /// (docs/tamper-evidence.md, #573). Returns nil on a network error (the |
| + /// monitor treats that as "nothing to conclude" and retries). Verification |
| + /// is on the body, so no HTTP-status check is needed here. |
| + public func checkpoints(after: Int64) async -> CheckpointsResponse? { |
| + let items = [ |
| + URLQueryItem(name: "token", value: token), |
| + URLQueryItem(name: "after", value: String(after)), |
| + ] |
| + return try? await getJSON("/api/checkpoints", CheckpointsResponse.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] { |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 2b6db46..4d14b29 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -165,6 +165,14 @@ public struct MessagePayload: Codable, Equatable, Sendable { |
| public var emoji: String? // the reaction emoji |
| public var on: Bool? // true to add, false to remove |
| + // Tamper-evidence gossip (docs/tamper-evidence.md, #573): the sender's |
| + // verified checkpoint head, `[seqTo, chain]`, riding inside the encrypted |
| + // payload so the server can neither read nor selectively strip it. Host |
| + // metadata, never chat content — read by the integrity monitor, then |
| + // ignored by rendering (the web strips it before folds; native just never |
| + // renders it). Optional, so it's absent on messages that don't gossip. |
| + public var _ckpt: CkptGossip? |
| + |
| /// Mirrors web/static/message.js acceptsMessageType: render untyped (legacy) |
| /// and "chat" payloads; skip unknown future kinds rather than mis-render them. |
| public var isRenderableChat: Bool { type == nil || type == "chat" } |
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift| index 356cbc3..57b9ee3 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| @@ -21,6 +21,34 @@ public final class NativeFeedModelCache { |
| private var entries: [String: Entry] = [:] // "instanceID|threadID" |
| private let maxEntries = 8 |
| + // One tamper-evidence monitor per instance (docs/tamper-evidence.md, #573), |
| + // shared by every thread's model so gossip from any chat feeds one chain |
| + // view. The app polls it (pollIntegrity) on connect/foreground and mounts |
| + // the failure banner on it. |
| + private var monitors: [String: IntegrityMonitor] = [:] |
| + |
| + /// The instance's integrity monitor, lazily created (per-instance |
| + /// UserDefaults storage). Observe its `unresolved` for the failure banner; |
| + /// read `reportLines()` for /integrity. |
| + public func integrityMonitor(for instance: SavedInstance) -> IntegrityMonitor { |
| + if let m = monitors[instance.id] { return m } |
| + let m = IntegrityMonitor(store: UserDefaultsIntegrityStore(host: instance.id)) |
| + monitors[instance.id] = m |
| + return m |
| + } |
| + |
| + /// Verify the instance's checkpoint chain now — the app calls this on |
| + /// connect + foreground (the native twin of the web's poll on ws.onopen + |
| + /// slow interval). Silent on a network error; alerts surface via the |
| + /// monitor's `unresolved` (the banner) and /integrity. |
| + 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) |
| + } |
| + } |
| + |
| // The latest decrypted roster names per instance (uid → name) — seeded |
| // into every model this cache hands out and pushed into parked ones on |
| // each ThreadStore fold, so bubble headers resolve against the current |
| @@ -60,12 +88,14 @@ public final class NativeFeedModelCache { |
| // filed while the app was suspended — merge it from disk now, so |
| // a notification tap lands on an already-caught-up chat. |
| let model = entry.model |
| + model.integrity = integrityMonitor(for: instance) |
| Task { await model.catchUpFromStore() } |
| return entry.model |
| } |
| entries.removeValue(forKey: key)?.model.stop() |
| let model = ChatFeedModel(instance: instance, thread: thread, myUserId: myUserId) |
| model.senderNames = senderNames[instance.id] ?? [:] |
| + model.integrity = integrityMonitor(for: instance) // tamper-evidence gossip |
| model.start() |
| entries[key] = Entry(model: model, seedKey: thread.threadKeyB64) |
| evictIfNeeded() |
diff --git a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift| index afa83db..0b715f2 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift |
| @@ -11,6 +11,7 @@ public enum SlashCommand: Equatable { |
| case setTheme(String) // apply a native theme id |
| case openThemes // open the theme picker (unknown/blank /theme) |
| case help // list available commands (private notice) |
| + case integrity // tamper-evidence status (private notice, #573) |
| case sendAsMessage // not a client command — send the text as-is |
| /// Classify composer text. `meName` is the sender's screen name (for /me). |
| @@ -36,6 +37,8 @@ public enum SlashCommand: Equatable { |
| return .openThemes |
| case "/help": |
| return .help |
| + case "/integrity": |
| + return .integrity |
| default: |
| // Unknown or bot-owned (/dark, /light, /profile, …) → send it; a bot |
| // answers where present, exactly like the web's bot-command path. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/CheckpointTests.swift b/ios/LChatCore/Tests/LChatCoreTests/CheckpointTests.swift| new file mode 100644 |
| index 0000000..9ea3185 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/CheckpointTests.swift |
| @@ -0,0 +1,258 @@ |
| +import XCTest |
| +import CryptoKit |
| +@testable import LChatCore |
| + |
| +// Native half of the tamper-evidence wire format (#573 slices 1+2). The |
| +// chain-hash vector here is the SAME frozen value checkpoint_test.go |
| +// (TestCheckpointHashVectors) and integrity.test.mjs pin: if any of the three |
| +// drifts, the platforms disagree about what a valid chain is. Fix the code, |
| +// not the vector. |
| +final class CheckpointTests: XCTestCase { |
| + |
| + // MARK: test key + checkpoint builder |
| + |
| + private let key = Curve25519.Signing.PrivateKey() |
| + private var pubB64: String { key.publicKey.rawRepresentation.base64EncodedString() } |
| + |
| + private struct BuiltRow { let row: CheckpointRow; let chain: String } |
| + |
| + private func mkCheckpoint(id: Int64, seqFrom: Int64, seqTo: Int64, prevChain: String, |
| + signKey: Curve25519.Signing.PrivateKey? = nil, pub: String? = nil, |
| + badSig: String? = nil, rowChain: String? = nil) -> BuiltRow { |
| + let root = String(String(repeating: String(seqTo), count: 4).prefix(64)).padding(toLength: 64, withPad: "a", startingAt: 0) |
| + let chain = Checkpoint.chainHashHex(prev: prevChain, root: root, seqTo: seqTo) |
| + let payload: [String: Any] = [ |
| + "seq_from": seqFrom, "seq_to": seqTo, "root": root, |
| + "prev_chain": prevChain, "chain": chain, "ts": 1_700_000_000, "pub": pub ?? pubB64, |
| + ] |
| + let payloadData = try! JSONSerialization.data(withJSONObject: payload) |
| + let payloadB64 = payloadData.base64EncodedString() |
| + let sig = try! badSig ?? (signKey ?? key).signature(for: Data(payloadB64.utf8)).base64EncodedString() |
| + let wrapper = try! JSONSerialization.data(withJSONObject: ["payload": payloadB64, "sig": sig]) |
| + let row = CheckpointRow(id: id, seq_from: seqFrom, seq_to: seqTo, root: root, |
| + chain: rowChain ?? chain, ts: 1_700_000_000, signed: String(data: wrapper, encoding: .utf8)!) |
| + return BuiltRow(row: row, chain: chain) |
| + } |
| + |
| + // MARK: pure core |
| + |
| + func testChainHashMatchesGoAndWebVector() { |
| + let got = Checkpoint.chainHashHex(prev: "", root: "7a94be3e3002c1afe379246c90db6fd04fdf438b0dc78da06ca6c3f130a5cec0", seqTo: 2) |
| + XCTAssertEqual(got, "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e") |
| + } |
| + |
| + func testVerifiesWellFormedTwoLinkPage() { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) |
| + let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: true) |
| + XCTAssertEqual(out.alerts.count, 0) |
| + XCTAssertFalse(out.fatal) |
| + XCTAssertEqual(out.head?.seqTo, 9) |
| + XCTAssertEqual(out.head?.pub, pubB64) |
| + XCTAssertEqual(out.hist.map { $0.seqTo }, [3, 9]) |
| + } |
| + |
| + func testTamperedSignatureStopsWalk() { |
| + let bad = Data(repeating: 0, count: 64).base64EncodedString() |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", badSig: bad) |
| + let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: true) |
| + XCTAssertEqual(out.alerts.first?.kind, "bad-signature") |
| + XCTAssertTrue(out.fatal) |
| + XCTAssertNil(out.head) |
| + } |
| + |
| + func testGarbageSigFieldAlertsNeverThrows() { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", badSig: "!!not-base64!!") |
| + let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: true) |
| + XCTAssertEqual(out.alerts.first?.kind, "bad-signature") |
| + XCTAssertTrue(out.fatal) |
| + } |
| + |
| + func testKeyRotationAlertsButWalkContinues() { |
| + let keyB = Curve25519.Signing.PrivateKey() |
| + let pubBB64 = keyB.publicKey.rawRepresentation.base64EncodedString() |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain, signKey: keyB, pub: pubBB64) |
| + let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: true) |
| + XCTAssertEqual(out.alerts.count, 1) |
| + XCTAssertEqual(out.alerts.first?.kind, "key-change") |
| + XCTAssertFalse(out.fatal) |
| + XCTAssertEqual(out.head?.seqTo, 9) |
| + XCTAssertEqual(out.head?.pub, pubBB64) |
| + } |
| + |
| + func testBrokenLinkStopsWalk() { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: String(repeating: "f", count: 64)) |
| + let out = Checkpoint.verifyPage([c1.row, c2.row], prior: nil, checkSigs: false) |
| + XCTAssertEqual(out.alerts.first?.kind, "broken-link") |
| + XCTAssertEqual(out.head?.seqTo, 3) // c1 stands, c2 rejected |
| + } |
| + |
| + func testRowColumnsDriftingStopsWalk() { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "", rowChain: String(repeating: "0", count: 64)) |
| + let out = Checkpoint.verifyPage([c1.row], prior: nil, checkSigs: false) |
| + XCTAssertEqual(out.alerts.first?.kind, "row-drift") |
| + } |
| + |
| + func testGossipVerdicts() { |
| + let head = CkptHead(id: 2, seqTo: 9, chain: "c9", pub: "x") |
| + let hist = [CkptGossip(seqTo: 3, chain: "c3"), CkptGossip(seqTo: 9, chain: "c9")] |
| + func v(_ s: Int64, _ c: String) -> String { Checkpoint.gossipVerdict(head: head, hist: hist, gossip: CkptGossip(seqTo: s, chain: c)) } |
| + XCTAssertEqual(v(9, "c9"), "match") |
| + XCTAssertEqual(v(9, "XX"), "fork") |
| + XCTAssertEqual(v(12, "cX"), "ahead") |
| + XCTAssertEqual(v(3, "c3"), "match") |
| + XCTAssertEqual(v(3, "XX"), "fork") |
| + XCTAssertEqual(v(5, "c5"), "fork") // inside window, not one of our boundaries |
| + XCTAssertEqual(v(2, "c2"), "unknown-old") // below the kept window |
| + XCTAssertEqual(Checkpoint.gossipVerdict(head: nil, hist: [], gossip: CkptGossip(seqTo: 9, chain: "c9")), "no-head") |
| + } |
| + |
| + // MARK: gossip wire tolerance (cross-platform contract) |
| + |
| + func testGossipEncodesAsHeterogeneousArray() throws { |
| + let g = CkptGossip(seqTo: 31, chain: "ef37d7a412bd") |
| + let data = try JSONEncoder().encode(g) |
| + XCTAssertEqual(String(data: data, encoding: .utf8), "[31,\"ef37d7a412bd\"]") |
| + let back = try JSONDecoder().decode(CkptGossip.self, from: Data("[31,\"ef37d7a412bd\"]".utf8)) |
| + XCTAssertEqual(back, g) |
| + } |
| + |
| + func testMessagePayloadToleratesCkpt() throws { |
| + // A payload carrying _ckpt must decode with the field read and every |
| + // other field intact — the web strips it before folds, but native must |
| + // at least tolerate + read it. |
| + let json = """ |
| + {"name":"Alice","text":"hi","ts":1700000000000,"uid":"u1","_ckpt":[9,"abc123"]} |
| + """ |
| + let p = try JSONDecoder().decode(MessagePayload.self, from: Data(json.utf8)) |
| + XCTAssertEqual(p.text, "hi") |
| + XCTAssertEqual(p.name, "Alice") |
| + XCTAssertEqual(p._ckpt, CkptGossip(seqTo: 9, chain: "abc123")) |
| + // A payload without _ckpt leaves it nil (encode omits it). |
| + let p2 = try JSONDecoder().decode(MessagePayload.self, from: Data(#"{"text":"hi"}"#.utf8)) |
| + XCTAssertNil(p2._ckpt) |
| + } |
| + |
| + // MARK: monitor |
| + |
| + private final class MemStore: IntegrityStore { |
| + var data: Data? |
| + func loadIntegrity() -> Data? { data } |
| + func saveIntegrity(_ d: Data) { data = d } |
| + } |
| + |
| + @MainActor |
| + func testPollAdvancesHeadAndGossipsOut() async { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let mon = IntegrityMonitor(store: MemStore()) |
| + await mon.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } |
| + XCTAssertEqual(mon.gossipOut(), CkptGossip(seqTo: 3, chain: c1.chain)) |
| + XCTAssertEqual(mon.unresolvedAlerts().count, 0) |
| + } |
| + |
| + @MainActor |
| + func testVanishedHeadIsRollback() async { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let mon = IntegrityMonitor(store: MemStore()) |
| + var serve = [c1.row] |
| + await mon.poll { _ in CheckpointsResponse(checkpoints: serve, public_key: self.pubB64) } |
| + serve = [] |
| + await mon.poll { _ in CheckpointsResponse(checkpoints: serve, public_key: self.pubB64) } |
| + XCTAssertEqual(mon.unresolvedAlerts().first?.kind, "rollback") |
| + } |
| + |
| + @MainActor |
| + func testForkGossipAlertsAndBannerAcknowledgeReRaises() async { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) |
| + var fired: [String] = [] |
| + var serve = [c1.row] |
| + let mon = IntegrityMonitor(store: MemStore()) |
| + mon.onAlert = { fired.append($0.kind) } |
| + await mon.poll { _ in CheckpointsResponse(checkpoints: serve.filter { $0.id > 0 }, public_key: self.pubB64) } |
| + // Forged gossip at our head → fork alert + banner. |
| + XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")), "fork") |
| + XCTAssertEqual(fired, ["gossip-fork"]) |
| + XCTAssertEqual(mon.unresolvedAlerts().count, 1) |
| + // Same-seq re-see is deduped. |
| + _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong2")) |
| + XCTAssertEqual(fired.count, 1) |
| + // Acknowledge clears the banner; the record stays. |
| + mon.acknowledge() |
| + XCTAssertEqual(mon.unresolvedAlerts().count, 0) |
| + XCTAssertEqual(mon.report().alertCount, 1) |
| + // A fork at a NEW head re-raises past the acknowledgement. |
| + serve = [c1.row, c2.row] |
| + await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| + XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 9, chain: "wrong3")), "fork") |
| + XCTAssertEqual(mon.unresolvedAlerts().count, 1) |
| + XCTAssertEqual(fired.count, 2) |
| + } |
| + |
| + @MainActor |
| + func testAheadResolvesThenWithholds() async { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let c2 = mkCheckpoint(id: 2, seqFrom: 4, seqTo: 9, prevChain: c1.chain) |
| + var t: Int64 = 0 |
| + var serve = [c1.row] |
| + let mon = IntegrityMonitor(store: MemStore(), now: { t }) |
| + await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| + // Peer verified c2's head before we saw it → pending, not an alert. |
| + XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 9, chain: c2.chain)), "ahead") |
| + serve = [c1.row, c2.row] |
| + await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| + XCTAssertEqual(mon.unresolvedAlerts().count, 0) // caught up, matched, resolved |
| + // A peer ahead of a head the server never shows us → withheld after grace. |
| + XCTAssertEqual(mon.gossipIn(CkptGossip(seqTo: 20, chain: String(repeating: "x", count: 64))), "ahead") |
| + t += IntegrityMonitor.aheadGraceMs + 1 |
| + await mon.poll { after in CheckpointsResponse(checkpoints: serve.filter { $0.id > after }, public_key: self.pubB64) } |
| + XCTAssertEqual(mon.unresolvedAlerts().last?.kind, "withheld") |
| + } |
| + |
| + @MainActor |
| + func testReportLinesPluraliseAndReflectState() async { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let mon = IntegrityMonitor(store: MemStore()) |
| + // No head yet. |
| + XCTAssertTrue(mon.reportLines().contains { $0.contains("No verified checkpoints yet") }) |
| + await mon.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } |
| + // One peer, one alert → singular nouns. |
| + _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")) |
| + let one = mon.reportLines().joined(separator: "\n") |
| + XCTAssertTrue(one.contains("Verified up to entry 3")) |
| + XCTAssertTrue(one.contains("Signatures: verified (Ed25519)")) |
| + XCTAssertTrue(one.contains("1 peer's record")) // singular |
| + XCTAssertTrue(one.contains("1 integrity alert —")) // singular, no "s" |
| + // A second, different peer + alert → plural nouns. |
| + _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "wrong"), countPeer: true) // deduped alert, still counts a peer |
| + _ = mon.gossipIn(CkptGossip(seqTo: 3, chain: "match-miss")) |
| + // Force a distinct second alert kind for the plural check. |
| + var empty = [CheckpointRow]() |
| + await mon.poll { _ in CheckpointsResponse(checkpoints: empty, public_key: self.pubB64) } |
| + empty = [] |
| + await mon.poll { _ in CheckpointsResponse(checkpoints: [], public_key: self.pubB64) } |
| + let two = mon.reportLines().joined(separator: "\n") |
| + XCTAssertTrue(two.contains("peers' records")) // plural |
| + XCTAssertFalse(mon.reportLines().isEmpty) |
| + } |
| + |
| + func testAlertMessagesAreMemberFacing() { |
| + XCTAssertTrue(IntegrityMonitor.alertMessage("gossip-fork").contains("disagrees with another member")) |
| + XCTAssertTrue(IntegrityMonitor.alertMessage("rollback").contains("older version")) |
| + XCTAssertEqual(IntegrityMonitor.alertMessage("something-new"), "This space failed an integrity check.") |
| + } |
| + |
| + @MainActor |
| + func testStatePersistsAcrossMonitors() async { |
| + let c1 = mkCheckpoint(id: 1, seqFrom: 1, seqTo: 3, prevChain: "") |
| + let shared = MemStore() |
| + let m1 = IntegrityMonitor(store: shared) |
| + await m1.poll { _ in CheckpointsResponse(checkpoints: [c1.row], public_key: self.pubB64) } |
| + _ = m1.gossipIn(CkptGossip(seqTo: 3, chain: "wrong")) // record a fork |
| + let m2 = IntegrityMonitor(store: shared) |
| + XCTAssertEqual(m2.gossipOut(), CkptGossip(seqTo: 3, chain: c1.chain)) |
| + XCTAssertEqual(m2.unresolvedAlerts().first?.kind, "gossip-fork") |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift| index 830d5f6..42b1658 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift |
| @@ -62,16 +62,17 @@ final class ComposerTypeaheadTests: XCTestCase { |
| // itself: the panel commands (/emoji, /gif — the command-host webview, |
| // #497), /me, and theming (SlashCommands.swift) — and nothing that |
| // only a bot could answer (/help would post into the void). Ordered |
| - // like the web's commandCatalog: panels, /me, then the bot floor. |
| - XCTAssertEqual(commandNames(hasBot: false), ["/emoji", "/gif", "/me", "/theme", "/themes"]) |
| + // like the web's commandCatalog: panels, /me, /integrity, then the bot floor. |
| + XCTAssertEqual(commandNames(hasBot: false), ["/emoji", "/gif", "/me", "/integrity", "/theme", "/themes"]) |
| } |
| func testBotfulChatOffersTheFullFloor() { |
| - // Nine commands exist with a bot present but the typeahead caps at 8 |
| - // (the web's cap too) — the ordering guarantees the victim is the |
| - // TAIL of the bot list (/profile), never /me or a panel command. |
| + // Ten commands exist with a bot present but the typeahead caps at 8 |
| + // (the web's cap too) — the ordering guarantees the victims are the |
| + // TAIL of the bot list (/light, /profile), never /me, /integrity, or a |
| + // panel command. |
| XCTAssertEqual(commandNames(hasBot: true), |
| - ["/emoji", "/gif", "/me", "/help", "/theme", "/themes", "/dark", "/light"]) |
| + ["/emoji", "/gif", "/me", "/integrity", "/help", "/theme", "/themes", "/dark"]) |
| // Every bot command is still reachable the moment its prefix is typed — |
| // the cap hides, it never disables. |
| for expected in botCommands.map(\.name) { |
| @@ -165,7 +166,7 @@ final class ComposerTypeaheadTests: XCTestCase { |
| let bare = ComposerToken(kind: .command, query: "", start: 0) |
| XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: false, |
| appCommands: add).map(\.insert), |
| - ["/add ", "/emoji ", "/gif ", "/me ", "/theme ", "/themes "], |
| + ["/add ", "/emoji ", "/gif ", "/me ", "/integrity ", "/theme ", "/themes "], |
| "no bot → the local verb leads; client-answered floor follows") |
| XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: true, |
| appCommands: add).map(\.insert).first, |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index a537eae..d5ab6c2 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -173,6 +173,7 @@ struct NativeFeedView: View { |
| // The native composer's draft — cleared only when a send actually reaches |
| // the server (model.send returns false on a down socket, keeping the text). |
| @State private var draft = "" |
| + @State private var integrityReport: String? // /integrity read-out (tamper evidence) |
| @FocusState private var composerFocused: Bool |
| // Attachments staged on the draft: picked → uploading (spinner) → ready. |
| // Several can ride one message, like the web's pending list. |
| @@ -465,6 +466,27 @@ struct NativeFeedView: View { |
| // failure that got the original full-inset .bar slab removed). |
| composer |
| } |
| + // Tamper-evidence failure banner (docs/tamper-evidence.md) — only when |
| + // this space fails an integrity check; "What's this?" fills the report |
| + // alert below. |
| + .safeAreaInset(edge: .top, spacing: 0) { |
| + if let instance { |
| + IntegrityBanner(monitor: NativeFeedModelCache.shared.integrityMonitor(for: instance)) { |
| + integrityReport = NativeFeedModelCache.shared |
| + .integrityMonitor(for: instance).reportLines().joined(separator: "\n") |
| + } |
| + } |
| + } |
| + .alert("Integrity check", isPresented: .init( |
| + get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } }) |
| + ) { |
| + Button("OK", role: .cancel) { integrityReport = nil } |
| + } message: { |
| + Text(integrityReport ?? "") |
| + } |
| + .task(id: instance?.id) { |
| + if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) } |
| + } |
| // Track the feed's window frame so the swipe monitor only claims |
| // scroll events that happen over it. |
| .background(GeometryReader { g in |
| @@ -1208,6 +1230,11 @@ struct NativeFeedView: View { |
| draft = "" |
| openThemeSettings() |
| return |
| + case .integrity: |
| + draft = "" |
| + integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") } |
| + ?? "Integrity monitoring isn't active for this space." |
| + return |
| case .help, .sendAsMessage: |
| break // fall through — a bot answers /help and bot commands |
| } |