Transitive verification: vouch for verified peers over Noise (#1380)

* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Transitive verification: vouch for verified peers over Noise

When a Noise session establishes with a peer I verified and that peer
advertises the .vouch capability, send signed attestations (up to 16,
most recently verified first, at most once per peer per 24h) for the
OTHER fingerprints I verified. Receivers accept vouches only from
senders they verified themselves, verify the Ed25519 signature against
the sender's announce-bound signing key, and surface the result as a
new derived trust tier: vouched (unfilled seal) between casual and
trusted.

Protocol:
- NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations:
  voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp
  (uint64 ms BE), Ed25519 signature over
  "bitchat-vouch-v1" | fingerprint | signingKey | timestamp.
  The voucher is implicit in the authenticated session.
- PeerCapabilities.localSupported now advertises .vouch.

Storage (SecureIdentityStateManager / IdentityCache):
- vouches keyed by vouchee, capped at 8 vouchers each; validity is
  recomputed on read (voucher still verified-by-me, < 30 days old), so
  unverifying a voucher retires their vouches without cascade deletes.
- New IdentityCache fields are Optional so pre-existing encrypted
  caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but
  raw values are strings, so persisted values are unaffected (and
  vouched itself is never persisted).
- Panic wipe clears vouch state with the rest of the identity cache.

UI: unfilled checkmark.seal badge in the mesh peer list (filled seal
stays exclusive to verified) and a "vouched for by N people you
verified" section with voucher names in FingerprintView; VoiceOver
labels and xcstrings entries included.

Tests: attestation encode/decode + signature (forged/tampered/expired),
accept-policy gates, batch cap, trust-level derivation incl. voucher
invalidation, persistence compat, and coordinator exchange/accept
policies. Full macOS suite: 1088 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance

Two fixes for PR #1380 review findings:

1. CI "Run Swift Tests (app)" hang (exit 137): the new
   SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift
   Testing ran its tests in parallel on the Swift Concurrency cooperative
   pool. Each test enqueues a queue.async(.barrier) write (setVerified)
   and immediately blocks in queue.sync / queue.sync(.barrier)
   (recordVouch / effectiveTrustLevel). On CI's few-core runners every
   cooperative-pool thread ended up parked behind a pending barrier that
   never got a dispatch worker, deadlocking the whole test process until
   the watchdog SIGKILLed it. The suite is now @MainActor, matching the
   production isolation of the vouch API (ChatVouchCoordinator is
   @MainActor) and keeping blocking syncs off the cooperative pool.

2. Codex P2: an open fingerprint sheet did not refresh its vouched badge
   when a vouch batch was accepted - VerificationModel.bind() never
   observed the trust-change signal. It now subscribes to the
   "peerStatusUpdated" notification that
   ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source
   PeerListModel uses) and forwards it to objectWillChange. Added a
   regression test that pins VerificationModel's own subscription
   (verified to fail without the fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Skip media-wipe detached tasks under tests (shared-filesystem race)

panicClearAllData and clearCurrentPublicTimeline delete the real
~/Library/Application Support/files tree in detached utility-priority
tasks. The SPM test process shares that tree and ChatViewModelTests
invoke both methods, so under parallel scheduling the wipe lands at a
nondeterministic time — deleting media a concurrently running test just
wrote (and the developer's real app data with it). Guard both with the
existing TestEnvironment.isRunningTests pattern, mirroring the same fix
on feat/mesh-diagnostics (#1377).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Port vouch capability-race fix to feat/vouching (ports b8adcbe9)

Ports the on-device-confirmed fix from the integration test branch
(commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct.
On-device testing confirmed the transitive vouch propagated once the
send was triggered on verify / announce arrival rather than auth alone.

Vouch attestations only ever sent from peerAuthenticated, gated on the
peer's .vouch capability. That capability arrives via the peer's announce,
processed independently of the Noise handshake, so at auth time the set was
usually empty -> gate failed -> vouch silently skipped and never retried.

- Refactor the send path into a reusable attemptVouch(to:fingerprint:now:).
- Trigger on peer-list updates (peersUpdated): fired after every verified
  announce, so the batch goes out once the .vouch bit actually arrives.
- Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a
  peer runs a vouch pass over connected verified peers, covering the
  verify-while-connected case and propagating the new identity onward.
- Relax the capability gate: treat an empty/unknown set as eligible (the
  Noise 0x12 payload is ignored by non-supporting peers); only skip when a
  non-empty set explicitly lacks .vouch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 14:48:37 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 70229f0be1
commit 2360140760
26 changed files with 1957 additions and 19 deletions
+39
View File
@@ -591,6 +591,45 @@ struct AppArchitectureTests {
#expect(!verificationModel.isVerified(peerID: peerID))
}
@Test("VerificationModel refreshes when peer trust changes (vouch accepted)")
@MainActor
func verificationModelRefreshesOnPeerTrustChange() async {
let viewModel = makeArchitectureViewModel()
var privateConversationModel: PrivateConversationModel? = PrivateConversationModel(
chatViewModel: viewModel,
conversations: viewModel.conversations,
locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager())
)
let verificationModel = VerificationModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel!
)
// PrivateConversationModel happens to observe the same notification
// and re-assign its published selection, which would ripple into
// VerificationModel; release it so this test pins VerificationModel's
// own subscription rather than that incidental chain.
privateConversationModel = nil
// The bound @Published sources replay their current values on
// subscription; let those initial main-queue emissions settle so the
// sink below observes only the trust-change signal.
try? await Task.sleep(nanoseconds: 100_000_000)
// ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted
// vouches via "peerStatusUpdated"; an open fingerprint sheet must
// re-render its vouched badge from that signal alone.
var refreshed = false
let cancellable = verificationModel.objectWillChange.sink { _ in
refreshed = true
}
defer { cancellable.cancel() }
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
await waitUntil { refreshed }
#expect(refreshed)
}
@Test("PeerListModel publishes mesh and geohash directory state")
@MainActor
func peerListModelPublishesDirectoryState() async {
@@ -156,6 +156,12 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verifyResponsePayloads.append((peerID, payload))
}
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchPayloads.append((peerID, payload))
}
}
// MARK: - Helpers
@@ -51,6 +51,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
func saveIdentityState() { saveIdentityStateCount += 1 }
private(set) var vouchToConnectedVerifiedPeersCount = 0
func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 }
// Encryption status
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var updatedEncryptionStatusPeers: [PeerID] = []
@@ -0,0 +1,353 @@
//
// ChatVouchCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext`
// proving the exchange policy (verified + capable peers only, batch cap,
// 24h rate limit) and the accept policy (verified senders only, real
// Ed25519 signature verification, expiry) without a `ChatViewModel`.
// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee
// cap) are covered by `SecureIdentityStateManagerVouchTests`.
//
import CryptoKit
import Foundation
import BitFoundation
import Testing
@testable import bitchat
// MARK: - Mock Context
@MainActor
private final class MockChatVouchContext: ChatVouchContext {
// Identity & trust state
var fingerprintsByPeerID: [PeerID: String] = [:]
var verifiedFingerprints: Set<String> = []
var signingKeysByFingerprint: [String: Data] = [:]
var recentVerified: [String] = []
private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = []
private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = []
var recordVouchResult = true
var lastBatchSentAt: [String: Date] = [:]
private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = []
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) }
func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] }
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
recentVerifiedRequests.append((limit, fingerprint))
return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit))
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp))
return recordVouchResult
}
func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] }
func markVouchBatchSent(to fingerprint: String, at date: Date) {
markedBatchSent.append((fingerprint, date))
lastBatchSentAt[fingerprint] = date
}
// Transport
var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:]
var mySigningKey = Curve25519.Signing.PrivateKey()
private(set) var installedObservers: [(PeerID, String) -> Void] = []
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
var connectedPeerIDList: [PeerID] = []
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
func connectedPeerIDs() -> [PeerID] { connectedPeerIDList }
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
installedObservers.append(handler)
}
func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sentVouchPayloads.append((payload, peerID))
}
// UI refresh
private(set) var trustChangedCount = 0
func notifyPeerTrustChanged() { trustChangedCount += 1 }
}
// MARK: - Tests
struct ChatVouchCoordinatorContextTests {
private let peerID = PeerID(str: "1122334455667788")
private let peerFingerprint = String(repeating: "0f", count: 32)
@MainActor
private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) {
let context = MockChatVouchContext()
let coordinator = ChatVouchCoordinator(context: context)
context.fingerprintsByPeerID[peerID] = peerFingerprint
context.verifiedFingerprints.insert(peerFingerprint)
context.capabilitiesByPeerID[peerID] = [.vouch]
return (context, coordinator)
}
// MARK: Exchange policy
@Test @MainActor
func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)]
context.recentVerified = vouchees
for vouchee in vouchees {
context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32)
}
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
// Candidates are requested most-recent-first, excluding the target.
#expect(context.recentVerifiedRequests.count == 1)
#expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount)
#expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint)
let sent = try #require(context.sentVouchPayloads.first)
#expect(sent.peerID == peerID)
let attestations = VouchAttestation.decodeList(from: sent.payload)
#expect(attestations.map(\.voucheeFingerprintHex) == vouchees)
// Every attestation carries a valid signature under our signing key.
let myPublicKey = context.mySigningKey.publicKey.rawRepresentation
#expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) })
// The rate limit is stamped only after an actual send.
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
}
@Test @MainActor
func peerAuthenticated_requiresVerificationAndCapability() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
// Not verified by me: nothing.
context.verifiedFingerprints.remove(peerFingerprint)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.isEmpty)
// Verified but advertises a non-empty capability set lacking .vouch:
// nothing. (An *empty*/unknown set is race-tolerant and still sends
// see `attemptVouch_sendsWhenCapabilitiesUnknown`.)
context.verifiedFingerprints.insert(peerFingerprint)
context.capabilitiesByPeerID[peerID] = [.prekeys]
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.isEmpty)
#expect(context.markedBatchSent.isEmpty)
}
// MARK: Capability race tolerance & new triggers
@Test @MainActor
func attemptVouch_sendsWhenCapabilitiesUnknown() {
// Capability set still empty at attempt time (the peer's .vouch bit
// arrives on a later announce): the batch must still go out.
let (context, coordinator) = makeVerifiedCapablePeer()
context.capabilitiesByPeerID[peerID] = []
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
#expect(context.sentVouchPayloads.count == 1)
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
}
@Test @MainActor
func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.connectedPeerIDList = [peerID]
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
// Session is already up (no peerAuthenticated re-fire); the verify pass
// is what makes the batch go out.
coordinator.vouchToConnectedVerifiedPeers()
let sent = context.sentVouchPayloads
#expect(sent.count == 1)
#expect(sent.first?.peerID == peerID)
#expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
}
@Test @MainActor
func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.connectedPeerIDList = [peerID]
context.verifiedFingerprints.remove(peerFingerprint)
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
coordinator.vouchToConnectedVerifiedPeers()
#expect(context.sentVouchPayloads.isEmpty)
}
@Test @MainActor
func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
// First announce before the .vouch bit is known: empty set is
// race-tolerant, so it already sends and stamps the throttle.
context.capabilitiesByPeerID[peerID] = []
coordinator.peersUpdated([peerID])
#expect(context.sentVouchPayloads.count == 1)
// A later announce carrying .vouch must not double-send (throttled).
context.capabilitiesByPeerID[peerID] = [.vouch]
coordinator.peersUpdated([peerID])
#expect(context.sentVouchPayloads.count == 1)
}
@Test @MainActor
func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
let (context, coordinator) = makeVerifiedCapablePeer()
context.recentVerified = [String(repeating: "01", count: 32)]
context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
let now = Date()
context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
#expect(context.sentVouchPayloads.isEmpty)
// Once the interval has elapsed the batch goes out again.
context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
#expect(context.sentVouchPayloads.count == 1)
}
@Test @MainActor
func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() {
let (context, coordinator) = makeVerifiedCapablePeer()
let withKey = String(repeating: "01", count: 32)
let withoutKey = String(repeating: "02", count: 32)
context.recentVerified = [withoutKey, withKey]
context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32)
coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload)
#expect(attestations.map(\.voucheeFingerprintHex) == [withKey])
// No signable candidates at all: nothing is sent or rate-stamped.
let freshPeer = PeerID(str: "aabbccddeeff0011")
let freshFingerprint = String(repeating: "0e", count: 32)
context.fingerprintsByPeerID[freshPeer] = freshFingerprint
context.verifiedFingerprints.insert(freshFingerprint)
context.capabilitiesByPeerID[freshPeer] = [.vouch]
context.recentVerified = [withoutKey]
coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint)
#expect(context.sentVouchPayloads.count == 1)
#expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint })
}
// MARK: Accept policy
@MainActor
private func makeInboundBatch(
signedBy key: Curve25519.Signing.PrivateKey,
vouchee: String = String(repeating: "07", count: 32),
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
) throws -> Data {
let voucheeData = try #require(Data(hexString: vouchee))
let attestation = try #require(VouchAttestation.build(
voucheeFingerprint: voucheeData,
voucheeSigningKey: Data(repeating: 0x44, count: 32),
timestampMs: timestampMs,
sign: { try? key.signature(for: $0) }
))
return try #require(VouchAttestation.encodeList([attestation]))
}
@Test @MainActor
func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
let vouchee = String(repeating: "07", count: 32)
let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee)
coordinator.handleVouchPayload(from: peerID, payload: payload)
#expect(context.recordedVouches.count == 1)
#expect(context.recordedVouches.first?.vouchee == vouchee)
#expect(context.recordedVouches.first?.voucher == peerFingerprint)
#expect(context.trustChangedCount == 1)
}
@Test @MainActor
func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
let payload = try makeInboundBatch(signedBy: senderKey)
// Sender's fingerprint is not in my verified set.
context.verifiedFingerprints.remove(peerFingerprint)
coordinator.handleVouchPayload(from: peerID, payload: payload)
#expect(context.recordedVouches.isEmpty)
// Unknown peer entirely.
coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload)
#expect(context.recordedVouches.isEmpty)
#expect(context.trustChangedCount == 0)
}
@Test @MainActor
func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
// Signed by an imposter key: signature check against the sender's
// announce-bound key fails.
let imposter = Curve25519.Signing.PrivateKey()
let forged = try makeInboundBatch(signedBy: imposter)
coordinator.handleVouchPayload(from: peerID, payload: forged)
#expect(context.recordedVouches.isEmpty)
// Correctly signed but expired.
let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000)
let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs)
coordinator.handleVouchPayload(from: peerID, payload: expired)
#expect(context.recordedVouches.isEmpty)
#expect(context.trustChangedCount == 0)
// No signing key known for the sender: batch dropped.
context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint)
let valid = try makeInboundBatch(signedBy: senderKey)
coordinator.handleVouchPayload(from: peerID, payload: valid)
#expect(context.recordedVouches.isEmpty)
}
@Test @MainActor
func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws {
let (context, coordinator) = makeVerifiedCapablePeer()
let senderKey = Curve25519.Signing.PrivateKey()
context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
context.recordVouchResult = false // e.g. self-vouch dropped by the store
let payload = try makeInboundBatch(signedBy: senderKey)
coordinator.handleVouchPayload(from: peerID, payload: payload)
#expect(context.recordedVouches.count == 1)
#expect(context.trustChangedCount == 0)
}
@Test @MainActor
func setupNoiseCallbacks_installsAdditiveObserver() {
let (context, coordinator) = makeVerifiedCapablePeer()
coordinator.setupNoiseCallbacks()
#expect(context.installedObservers.count == 1)
}
}
+45 -2
View File
@@ -107,12 +107,55 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) {}
func isVerified(fingerprint: String) -> Bool {
true
}
func getVerifiedFingerprints() -> Set<String> {
Set()
}
// MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
private var vouchBatchSentAt: [String: Date] = [:]
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
guard voucheeFingerprint != voucherFingerprint else { return false }
var records = vouchesByVouchee[voucheeFingerprint] ?? []
records.removeAll { $0.voucherFingerprint == voucherFingerprint }
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
vouchesByVouchee[voucheeFingerprint] = records
return true
}
func validVouchers(for fingerprint: String) -> [VouchRecord] {
vouchesByVouchee[fingerprint] ?? []
}
func isVouched(fingerprint: String) -> Bool {
!(vouchesByVouchee[fingerprint] ?? []).isEmpty
}
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
socialIdentities[fingerprint]?.trustLevel ?? .unknown
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
vouchBatchSentAt[fingerprint]
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
vouchBatchSentAt[fingerprint] = date
}
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
nil
}
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
[]
}
}
@@ -0,0 +1,176 @@
import CryptoKit
import Foundation
import Testing
@testable import bitchat
struct VouchAttestationTests {
private let voucherKey = Curve25519.Signing.PrivateKey()
private func makeAttestation(
fingerprint: Data = Data(repeating: 0xAA, count: 32),
signingKey: Data = Data(repeating: 0xBB, count: 32),
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
signedBy key: Curve25519.Signing.PrivateKey? = nil
) throws -> VouchAttestation {
let signer = key ?? voucherKey
return try #require(
VouchAttestation.build(
voucheeFingerprint: fingerprint,
voucheeSigningKey: signingKey,
timestampMs: timestampMs,
sign: { try? signer.signature(for: $0) }
)
)
}
@Test
func roundTripsAndVerifiesSignature() throws {
let attestation = try makeAttestation()
let encoded = try #require(attestation.encode())
let decoded = try #require(VouchAttestation.decode(from: encoded))
#expect(decoded == attestation)
#expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32))
#expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
}
@Test
func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws {
let attestation = try makeAttestation()
var encoded = try #require(attestation.encode())
// Unknown TLV appended: skipped for forward compatibility.
encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02])
#expect(VouchAttestation.decode(from: encoded) == attestation)
// Truncation and missing fields are rejected.
#expect(VouchAttestation.decode(from: encoded.dropLast()) == nil)
#expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil)
#expect(VouchAttestation.decode(from: Data()) == nil)
// Wrong field sizes are rejected.
var wrongSize = Data([0x01, 0x10])
wrongSize.append(Data(repeating: 0xAA, count: 16))
#expect(VouchAttestation.decode(from: wrongSize) == nil)
}
@Test
func buildRejectsWrongKeyAndFingerprintSizes() {
let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) }
#expect(VouchAttestation.build(
voucheeFingerprint: Data(repeating: 0xAA, count: 16),
voucheeSigningKey: Data(repeating: 0xBB, count: 32),
sign: sign
) == nil)
#expect(VouchAttestation.build(
voucheeFingerprint: Data(repeating: 0xAA, count: 32),
voucheeSigningKey: Data(repeating: 0xBB, count: 16),
sign: sign
) == nil)
}
@Test
func forgedSignatureFailsVerification() throws {
let attestation = try makeAttestation()
let otherKey = Curve25519.Signing.PrivateKey()
// Verifying against a key that didn't sign fails.
#expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation))
// An attestation signed by an imposter fails against the real key.
let forged = try makeAttestation(signedBy: otherKey)
#expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
#expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3)))
}
@Test
func tamperedFieldsFailVerification() throws {
let attestation = try makeAttestation()
let publicKey = voucherKey.publicKey.rawRepresentation
var tamperedFingerprint = attestation.voucheeFingerprint
tamperedFingerprint[0] ^= 0xFF
let tampered = VouchAttestation(
voucheeFingerprint: tamperedFingerprint,
voucheeSigningKey: attestation.voucheeSigningKey,
timestampMs: attestation.timestampMs,
signature: attestation.signature
)
#expect(!tampered.verifySignature(voucherSigningKey: publicKey))
let backdated = VouchAttestation(
voucheeFingerprint: attestation.voucheeFingerprint,
voucheeSigningKey: attestation.voucheeSigningKey,
timestampMs: attestation.timestampMs - 1,
signature: attestation.signature
)
#expect(!backdated.verifySignature(voucherSigningKey: publicKey))
}
@Test
func expiryWindowIsEnforced() throws {
let now = Date()
let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000))
#expect(!fresh.isExpired(now: now))
let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60)
let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000))
#expect(expired.isExpired(now: now))
let farFuture = now.addingTimeInterval(2 * 60 * 60)
let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000))
#expect(fromTheFuture.isExpired(now: now))
// A verified-but-expired attestation still has a valid signature; the
// two checks are independent gates.
#expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
}
@Test
func batchRoundTripsAndEnforcesCap() throws {
let attestations = try (0..<3).map { index in
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
}
let payload = try #require(VouchAttestation.encodeList(attestations))
#expect(VouchAttestation.decodeList(from: payload) == attestations)
#expect(VouchAttestation.encodeList([]) == nil)
let tooMany = try (0..<17).map { index in
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
}
#expect(VouchAttestation.encodeList(tooMany) == nil)
}
@Test
func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws {
let attestations = try (0..<17).map { index in
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
}
// Hand-build an oversized batch that lies about its count.
var payload = Data([UInt8(attestations.count)])
for attestation in attestations {
let encoded = try #require(attestation.encode())
payload.append(UInt8(encoded.count >> 8))
payload.append(UInt8(encoded.count & 0xFF))
payload.append(encoded)
}
let decoded = VouchAttestation.decodeList(from: payload)
#expect(decoded.count == VouchAttestation.maxBatchCount)
#expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount)))
// A malformed middle entry is dropped without killing the batch.
let good = try makeAttestation()
let goodEncoded = try #require(good.encode())
var mixed = Data([2])
mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE])
mixed.append(UInt8(goodEncoded.count >> 8))
mixed.append(UInt8(goodEncoded.count & 0xFF))
mixed.append(goodEncoded)
#expect(VouchAttestation.decodeList(from: mixed) == [good])
#expect(VouchAttestation.decodeList(from: Data()) == [])
#expect(VouchAttestation.decodeList(from: Data([5])) == [])
}
}
@@ -0,0 +1,335 @@
import Foundation
import Testing
@testable import bitchat
/// Vouch storage, accept-policy gates, derived trust levels, and persistence
/// compatibility for `SecureIdentityStateManager`.
///
/// Ordering note: mutations use barrier blocks on the manager's concurrent
/// queue and reads use `queue.sync`, so a read submitted after a mutation
/// always observes it no polling needed.
///
/// `@MainActor` matches production (the manager's vouch API is driven by the
/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync`
/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift
/// Testing runs these tests in parallel on that pool, and on CI's few-core
/// runners every pool thread ended up parked in `queue.sync` behind a pending
/// `queue.async(.barrier)` write that never got a dispatch worker a
/// process-wide deadlock (watchdog SIGKILL, exit 137).
@MainActor
struct SecureIdentityStateManagerVouchTests {
private let voucher = String(repeating: "0a", count: 32)
private let vouchee = String(repeating: "0b", count: 32)
private func makeManager() -> SecureIdentityStateManager {
SecureIdentityStateManager(MockKeychain())
}
// MARK: - Accept-policy gates
@Test
func recordVouch_rejectsUnverifiedVoucher() {
let manager = makeManager()
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
#expect(manager.validVouchers(for: vouchee).isEmpty)
manager.setVerified(fingerprint: voucher, verified: true)
#expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
#expect(manager.validVouchers(for: vouchee).count == 1)
}
@Test
func recordVouch_ignoresSelfVouch() {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
#expect(!manager.recordVouch(voucheeFingerprint: voucher, voucherFingerprint: voucher, timestamp: Date()))
#expect(manager.validVouchers(for: voucher).isEmpty)
}
@Test
func recordVouch_ignoresAlreadyVerifiedVouchee() {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
#expect(!manager.isVouched(fingerprint: vouchee))
}
@Test
func recordVouch_rejectsStaleAndFarFutureTimestamps() {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
let stale = Date().addingTimeInterval(-31 * 24 * 60 * 60)
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: stale))
let farFuture = Date().addingTimeInterval(2 * 60 * 60)
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: farFuture))
#expect(manager.validVouchers(for: vouchee).isEmpty)
}
@Test
func recordVouch_capsVouchersPerVoucheeKeepingMostRecent() {
let manager = makeManager()
let base = Date()
// 9 verified vouchers vouch with strictly increasing timestamps.
let vouchers = (0..<9).map { String(format: "%02x", $0 + 0x10) + String(repeating: "00", count: 31) }
for (index, voucherFingerprint) in vouchers.enumerated() {
manager.setVerified(fingerprint: voucherFingerprint, verified: true)
let stored = manager.recordVouch(
voucheeFingerprint: vouchee,
voucherFingerprint: voucherFingerprint,
timestamp: base.addingTimeInterval(TimeInterval(index)),
now: base.addingTimeInterval(TimeInterval(index))
)
#expect(stored)
}
let records = manager.validVouchers(for: vouchee)
#expect(records.count == SecureIdentityStateManager.maxVouchersPerVouchee)
// The oldest voucher fell off the end.
#expect(!records.contains { $0.voucherFingerprint == vouchers[0] })
#expect(records.contains { $0.voucherFingerprint == vouchers[8] })
// An attestation older than everything retained is not stored.
let older = String(repeating: "0c", count: 32)
manager.setVerified(fingerprint: older, verified: true)
#expect(!manager.recordVouch(
voucheeFingerprint: vouchee,
voucherFingerprint: older,
timestamp: base.addingTimeInterval(-1),
now: base
))
// A repeat vouch from a retained voucher refreshes, not duplicates.
#expect(manager.recordVouch(
voucheeFingerprint: vouchee,
voucherFingerprint: vouchers[8],
timestamp: base.addingTimeInterval(100),
now: base.addingTimeInterval(100)
))
#expect(manager.validVouchers(for: vouchee).count == SecureIdentityStateManager.maxVouchersPerVouchee)
}
// MARK: - Derived trust & invalidation
@Test
func unverifyingVoucher_invalidatesTheirVouchesWithoutDeletingThem() {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
#expect(manager.isVouched(fingerprint: vouchee))
// Removing my verification of the voucher retires their vouches
manager.setVerified(fingerprint: voucher, verified: false)
#expect(!manager.isVouched(fingerprint: vouchee))
#expect(manager.validVouchers(for: vouchee).isEmpty)
// but the records survive: re-verifying the voucher restores them
// (recompute on read, no cascade delete).
manager.setVerified(fingerprint: voucher, verified: true)
#expect(manager.isVouched(fingerprint: vouchee))
}
@Test
func validVouchers_expireAtReadTime() {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
let now = Date()
let timestamp = now.addingTimeInterval(-29 * 24 * 60 * 60)
#expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: timestamp, now: now))
#expect(manager.isVouched(fingerprint: vouchee, now: now))
let twoDaysLater = now.addingTimeInterval(2 * 24 * 60 * 60)
#expect(manager.validVouchers(for: vouchee, now: twoDaysLater).isEmpty)
#expect(!manager.isVouched(fingerprint: vouchee, now: twoDaysLater))
}
@Test
func effectiveTrustLevel_slotsVouchedBetweenCasualAndTrusted() {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
// Unknown peer with a valid vouch reads as vouched.
#expect(manager.effectiveTrustLevel(for: vouchee) == .unknown)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
#expect(manager.effectiveTrustLevel(for: vouchee) == .vouched)
// Explicit trust outranks a vouch.
manager.updateSocialIdentity(SocialIdentity(
fingerprint: vouchee,
localPetname: nil,
claimedNickname: "bob",
trustLevel: .trusted,
isFavorite: false,
isBlocked: false,
notes: nil
))
#expect(manager.effectiveTrustLevel(for: vouchee) == .trusted)
// Explicit verification outranks everything.
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(manager.effectiveTrustLevel(for: vouchee) == .verified)
#expect(!manager.isVouched(fingerprint: vouchee))
// Losing the voucher downgrades vouched back to the stored level.
manager.setVerified(fingerprint: vouchee, verified: false)
manager.setVerified(fingerprint: voucher, verified: false)
#expect(manager.effectiveTrustLevel(for: vouchee) == .casual)
}
// MARK: - Exchange-policy state
@Test
func mostRecentlyVerifiedFingerprints_ordersAndExcludes() {
let manager = makeManager()
let first = String(repeating: "01", count: 32)
let second = String(repeating: "02", count: 32)
let third = String(repeating: "03", count: 32)
manager.setVerified(fingerprint: first, verified: true)
manager.setVerified(fingerprint: second, verified: true)
manager.setVerified(fingerprint: third, verified: true)
let ordered = manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: third)
#expect(ordered == [second, first])
let limited = manager.mostRecentlyVerifiedFingerprints(limit: 1, excluding: third)
#expect(limited == [second])
}
@Test
func vouchBatchSentAt_roundTrips() {
let manager = makeManager()
#expect(manager.lastVouchBatchSent(to: voucher) == nil)
let sentAt = Date(timeIntervalSince1970: 1_700_000_000)
manager.markVouchBatchSent(to: voucher, at: sentAt)
#expect(manager.lastVouchBatchSent(to: voucher) == sentAt)
}
@Test
func signingPublicKey_returnsAnnounceBoundKeyByFingerprint() async {
let manager = makeManager()
let signingKey = Data(repeating: 0x22, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: voucher,
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: signingKey,
claimedNickname: nil
)
let stored = await waitUntil { manager.signingPublicKey(forFingerprint: voucher) == signingKey }
#expect(stored)
#expect(manager.signingPublicKey(forFingerprint: vouchee) == nil)
}
// MARK: - Panic wipe
@Test
func clearAllIdentityData_wipesVouchState() async {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
manager.markVouchBatchSent(to: voucher, at: Date())
#expect(manager.isVouched(fingerprint: vouchee))
manager.clearAllIdentityData()
let wiped = await waitUntil { !manager.isVouched(fingerprint: vouchee) }
#expect(wiped)
#expect(manager.validVouchers(for: vouchee).isEmpty)
#expect(manager.lastVouchBatchSent(to: voucher) == nil)
#expect(manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: "").isEmpty)
}
// MARK: - Persistence compatibility
@Test
func trustLevelRawValuesAreStable() throws {
// Raw values are what's persisted; they must never change when cases
// are added mid-ladder.
#expect(TrustLevel.unknown.rawValue == "unknown")
#expect(TrustLevel.casual.rawValue == "casual")
#expect(TrustLevel.vouched.rawValue == "vouched")
#expect(TrustLevel.trusted.rawValue == "trusted")
#expect(TrustLevel.verified.rawValue == "verified")
let legacy = Data(#"["unknown","casual","trusted","verified"]"#.utf8)
let decoded = try JSONDecoder().decode([TrustLevel].self, from: legacy)
#expect(decoded == [.unknown, .casual, .trusted, .verified])
}
@Test
func identityCachePersistedBeforeVouchingDecodesCleanly() throws {
// A cache captured before the vouch fields existed must decode without
// tripping the "unreadable cache" recovery path.
let legacyJSON = Data("""
{
"socialIdentities": {},
"nicknameIndex": {},
"verifiedFingerprints": ["\(voucher)"],
"lastInteractions": {},
"blockedNostrPubkeys": [],
"version": 1
}
""".utf8)
let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON)
#expect(decoded.vouchesByVouchee == nil)
#expect(decoded.vouchBatchSentAt == nil)
#expect(decoded.verifiedAt == nil)
#expect(decoded.verifiedFingerprints == [voucher])
}
@Test
func identityCacheRoundTripsVouchState() throws {
var cache = IdentityCache()
cache.verifiedFingerprints = [voucher]
cache.vouchesByVouchee = [vouchee: [VouchRecord(voucherFingerprint: voucher, timestamp: Date(timeIntervalSince1970: 1_700_000_000))]]
cache.vouchBatchSentAt = [voucher: Date(timeIntervalSince1970: 1_700_000_001)]
cache.verifiedAt = [voucher: Date(timeIntervalSince1970: 1_700_000_002)]
let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache))
#expect(decoded.vouchesByVouchee == cache.vouchesByVouchee)
#expect(decoded.vouchBatchSentAt == cache.vouchBatchSentAt)
#expect(decoded.verifiedAt == cache.verifiedAt)
}
@Test
func vouchStateSurvivesReload() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
let saved = await waitUntil { manager.isVouched(fingerprint: self.vouchee) }
#expect(saved)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
#expect(reloaded.isVouched(fingerprint: vouchee))
#expect(reloaded.validVouchers(for: vouchee).count == 1)
}
// MARK: - Helpers
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
@@ -292,4 +292,37 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
func getVerifiedFingerprints() -> Set<String> {
verified
}
// MARK: Vouching (unused by these tests)
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
false
}
func validVouchers(for fingerprint: String) -> [VouchRecord] {
[]
}
func isVouched(fingerprint: String) -> Bool {
false
}
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
verified.contains(fingerprint) ? .verified : .unknown
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
nil
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {}
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
nil
}
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
[]
}
}