eleven / messenger Public

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>
Paul Campbell e94f1b2d086b46bd4d108d9ea8e39c86e125891f parent 4331d9e
14 files changed, +953 −11
  • ios/LChat/Views/NativeChatScreen.swift +15 −0
  • ios/LChat/Views/ThreadListView.swift +30 −3
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +26 −0
  • ios/LChatCore/Sources/LChatCore/Checkpoint.swift +197 −0
  • ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift +6 −1
  • ios/LChatCore/Sources/LChatCore/IntegrityBanner.swift +60 −0
  • ios/LChatCore/Sources/LChatCore/IntegrityMonitor.swift +272 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +13 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +8 −0
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +30 −0
  • ios/LChatCore/Sources/LChatCore/SlashCommands.swift +3 −0
  • ios/LChatCore/Tests/LChatCoreTests/CheckpointTests.swift +258 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift +8 −7
  • ios/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
}