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
+11 -8
View File
@@ -14,6 +14,9 @@ struct MeshPeerRow: Identifiable, Equatable {
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool
/// Vouched-for by someone I verified, without an explicit verification of
/// mine rendered as the unfilled seal (verified gets the filled one).
let showsVouchedBadge: Bool
var id: String { peerID.id }
}
@@ -183,13 +186,12 @@ final class PeerListModel: ObservableObject {
let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let verifiedBadge: Bool
if !isMe && !peer.isConnected,
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
} else {
verifiedBadge = false
}
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
// Vouched is subordinate to verified: never show both seals.
let vouchedBadge = !isVerifiedFingerprint
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
return MeshPeerRow(
peerID: peer.peerID,
@@ -202,7 +204,8 @@ final class PeerListModel: ObservableObject {
isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge
showsVerifiedBadgeWhenOffline: verifiedBadge,
showsVouchedBadge: vouchedBadge
)
}
+40 -1
View File
@@ -9,6 +9,14 @@ struct FingerprintPresentationState: Equatable {
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
/// Number of currently-valid vouches from peers the user verified
/// (0 when the peer is explicitly verified the stronger badge wins).
let voucherCount: Int
/// Display names of the (verified) vouchers, where known.
let voucherNames: [String]
/// Vouched for by 1 peer the user verified (and not explicitly verified).
var isVouched: Bool { voucherCount > 0 }
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
@@ -82,6 +90,24 @@ final class VerificationModel: ObservableObject {
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
// Vouch state is recomputed on read: only vouchers still in the
// verified set count, so removing a verification silently retires the
// vouches that peer gave.
let vouchers: [VouchRecord]
if !isVerified, let theirFingerprint {
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
} else {
vouchers = []
}
let voucherNames = vouchers.compactMap { record -> String? in
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
return nil
}
if let petname = social.localPetname, !petname.isEmpty { return petname }
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
}
return FingerprintPresentationState(
statusPeerID: statusPeerID,
@@ -89,7 +115,9 @@ final class VerificationModel: ObservableObject {
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
isVerified: isVerified,
voucherCount: vouchers.count,
voucherNames: voucherNames
)
}
@@ -122,6 +150,17 @@ final class VerificationModel: ObservableObject {
self?.objectWillChange.send()
}
.store(in: &cancellables)
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
// are signalled via this notification rather than a published
// property, so an open fingerprint sheet refreshes its vouched badge
// live when a vouch batch is accepted.
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
+39
View File
@@ -126,13 +126,37 @@ struct SocialIdentity: Codable {
var notes: String?
}
/// Trust ladder: unknown casual vouched trusted verified.
///
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
/// value ("unknown", "casual", ), not by ordinal position, so inserting
/// `vouched` mid-ladder cannot corrupt previously persisted values every
/// pre-existing case keeps the exact raw value it was written with. The
/// `vouched` tier is additionally never persisted into `SocialIdentity`
/// (it's recomputed on read from stored vouches), so downgraded builds never
/// encounter the unfamiliar raw value.
enum TrustLevel: String, Codable {
case unknown
case casual
/// Transitively trusted: vouched for by at least one peer *I* verified.
/// Derived at read time never written to persistent storage.
case vouched
case trusted
case verified
}
// MARK: - Vouching (transitive verification)
/// One accepted vouch: a peer I verified (the voucher) attested that they
/// verified the vouchee. Validity is recomputed on read a record only
/// counts while its voucher remains in `verifiedFingerprints` and its
/// timestamp is within `VouchAttestation.maxAge` so unverifying a voucher
/// silently invalidates the vouches they gave without a cascade delete.
struct VouchRecord: Codable, Equatable {
let voucherFingerprint: String
let timestamp: Date
}
// MARK: - Identity Cache
/// Persistent storage for identity mappings and relationships.
@@ -155,6 +179,21 @@ struct IdentityCache: Codable {
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Vouching (transitive verification). All three fields are Optional so
// caches persisted before this feature decode cleanly the synthesized
// decoder uses decodeIfPresent for optionals, and a missing key must not
// trip the "unreadable cache" recovery path that discards everything.
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
var vouchesByVouchee: [String: [VouchRecord]]? = nil
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
var vouchBatchSentAt: [String: Date]? = nil
// Fingerprint -> when we verified it (orders outgoing vouch batches;
// entries verified before this field exists sort as oldest)
var verifiedAt: [String: Date]? = nil
// Schema version for future migrations
var version: Int = 1
}
@@ -133,6 +133,17 @@ protocol SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
// MARK: Vouching (transitive verification)
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func validVouchers(for fingerprint: String) -> [VouchRecord]
func isVouched(fingerprint: String) -> Bool
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
}
/// Singleton manager for secure identity state persistence and retrieval.
@@ -550,8 +561,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.async(flags: .barrier) {
if verified {
self.cache.verifiedFingerprints.insert(fingerprint)
var verifiedAt = self.cache.verifiedAt ?? [:]
verifiedAt[fingerprint] = Date()
self.cache.verifiedAt = verifiedAt
} else {
self.cache.verifiedFingerprints.remove(fingerprint)
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
}
// Update trust level if social identity exists
@@ -576,6 +591,159 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
// MARK: - Vouching (transitive verification)
/// Maximum vouchers retained per vouchee (most recent kept).
static let maxVouchersPerVouchee = 8
/// Records an accepted vouch, enforcing every accept-policy gate that can
/// be evaluated against stored state (signature verification is the
/// caller's job it needs the sender's announce-bound signing key):
/// - the voucher must be a fingerprint *I* verified
/// - self-vouches are ignored
/// - vouches for peers I already verified are ignored (nothing to add)
/// - attestations outside the validity window are ignored
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
///
/// Returns true when the vouch was stored (or refreshed).
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
recordVouch(
voucheeFingerprint: voucheeFingerprint,
voucherFingerprint: voucherFingerprint,
timestamp: timestamp,
now: Date()
)
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
queue.sync(flags: .barrier) {
guard voucheeFingerprint != voucherFingerprint,
self.cache.verifiedFingerprints.contains(voucherFingerprint),
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
return false
}
let age = now.timeIntervalSince(timestamp)
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
return false
}
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
let newest = max(records[index].timestamp, timestamp)
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
} else {
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
}
// Keep the most recent vouchers up to the cap.
records.sort { $0.timestamp > $1.timestamp }
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
return false // Full of fresher vouches; nothing changed.
}
var vouches = self.cache.vouchesByVouchee ?? [:]
vouches[voucheeFingerprint] = capped
self.cache.vouchesByVouchee = vouches
self.saveIdentityCache()
return true
}
}
/// The vouches that currently count for `fingerprint`. Validity is
/// recomputed here rather than maintained by cascade deletes: a record
/// only counts while its voucher is still verified-by-me and its
/// timestamp is within the expiry window.
func validVouchers(for fingerprint: String) -> [VouchRecord] {
validVouchers(for: fingerprint, now: Date())
}
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
queue.sync {
self.validVouchersLocked(for: fingerprint, now: now)
}
}
/// Requires `queue`.
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
return records.filter { record in
record.voucherFingerprint != fingerprint
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
}
}
/// True when the peer has at least one valid vouch and no explicit
/// verification of ours.
func isVouched(fingerprint: String) -> Bool {
isVouched(fingerprint: fingerprint, now: Date())
}
func isVouched(fingerprint: String, now: Date) -> Bool {
queue.sync {
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
}
}
/// The trust level to display: explicit verification wins, then the
/// persisted level, with `vouched` layered in (derived, never persisted)
/// between `casual` and `trusted`.
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
effectiveTrustLevel(for: fingerprint, now: Date())
}
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
queue.sync {
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
switch stored {
case .verified, .trusted:
return stored
case .vouched, .casual, .unknown:
if vouched { return .vouched }
// `.vouched` should never be persisted; degrade defensively.
return stored == .vouched ? .casual : stored
}
}
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
queue.async(flags: .barrier) {
var sentAt = self.cache.vouchBatchSentAt ?? [:]
sentAt[fingerprint] = date
self.cache.vouchBatchSentAt = sentAt
self.saveIdentityCache()
}
}
/// The peer's announce-bound Ed25519 signing key, if seen this session.
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
}
/// Verified fingerprints ordered most recently verified first (entries
/// without a recorded verification time sort last), excluding the given
/// fingerprint. Feeds the outgoing vouch batch.
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
queue.sync {
let verifiedAt = cache.verifiedAt ?? [:]
let ordered = cache.verifiedFingerprints
.filter { $0 != fingerprint }
.sorted {
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
}
return Array(ordered.prefix(limit))
}
}
var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex }
}
+70
View File
@@ -22479,6 +22479,18 @@
}
}
},
"fingerprint.badge.vouched" : {
"comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ VOUCHED"
}
}
}
},
"fingerprint.handshake_pending" : {
"extractionState" : "manual",
"localizations" : {
@@ -23016,6 +23028,40 @@
}
}
},
"fingerprint.message.vouched_by" : {
"comment" : "How many people the user verified have vouched for this peer",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "vouched for by %#@people@ you verified"
},
"substitutions" : {
"people" : {
"argNum" : 1,
"formatSpecifier" : "lld",
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%d person"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%d people"
}
}
}
}
}
}
}
}
},
"fingerprint.their_label" : {
"extractionState" : "manual",
"localizations" : {
@@ -31984,6 +32030,18 @@
}
}
},
"mesh_peers.state.vouched" : {
"comment" : "State label for a peer vouched for by someone the user verified",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "vouched"
}
}
}
},
"mesh_peers.tooltip.new_messages" : {
"extractionState" : "manual",
"localizations" : {
@@ -32163,6 +32221,18 @@
}
}
},
"mesh_peers.tooltip.vouched" : {
"comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "vouched for by someone you verified"
}
}
}
},
"recording %@" : {
"comment" : "Voice note recording duration indicator",
"localizations" : {
+3
View File
@@ -77,6 +77,8 @@ enum NoisePayloadType: UInt8 {
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations
var description: String {
switch self {
@@ -85,6 +87,7 @@ enum NoisePayloadType: UInt8 {
case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
}
}
}
@@ -3,5 +3,5 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = []
static let localSupported: PeerCapabilities = [.vouch]
}
+225
View File
@@ -0,0 +1,225 @@
//
// VouchAttestation.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
/// A signed statement that the *sender of the enclosing Noise payload* has
/// verified the identity described here ("transitive verification").
///
/// The voucher's identity is deliberately implicit: attestations only travel
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
/// receiver verifies the Ed25519 signature against the session peer's
/// announce-bound signing key and stores the vouch keyed by that peer's
/// fingerprint. Nothing in the attestation names the voucher, so a captured
/// attestation cannot be replayed by a third party whose signing key doesn't
/// match.
///
/// Wire format single attestation (TLV, 1-byte type + 1-byte length):
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
///
/// Unknown TLV types are skipped for forward compatibility.
///
/// Batch format (the `vouch` Noise payload body):
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
struct VouchAttestation: Equatable {
static let signingContext = "bitchat-vouch-v1"
/// Receiver-side expiry for attestations.
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
/// Tolerated clock skew for attestations timestamped in the future.
static let maxClockSkew: TimeInterval = 60 * 60
/// Upper bound of attestations carried/accepted in one batch payload.
static let maxBatchCount = 16
static let fingerprintSize = 32
static let signingKeySize = 32
static let signatureSize = 64
let voucheeFingerprint: Data // 32 bytes
let voucheeSigningKey: Data // 32 bytes
let timestampMs: UInt64
let signature: Data // 64 bytes
private enum TLVType: UInt8 {
case voucheeFingerprint = 0x01
case voucheeSigningKey = 0x02
case timestamp = 0x03
case signature = 0x04
}
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
/// The exact bytes the voucher signs.
static func signableBytes(
voucheeFingerprint: Data,
voucheeSigningKey: Data,
timestampMs: UInt64
) -> Data {
var message = Data(signingContext.utf8)
message.append(voucheeFingerprint)
message.append(voucheeSigningKey)
var timestampBE = timestampMs.bigEndian
withUnsafeBytes(of: &timestampBE) { message.append(contentsOf: $0) }
return message
}
var signableBytes: Data {
Self.signableBytes(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs
)
}
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
/// signing primitive (e.g. `Transport.noiseSignData`).
static func build(
voucheeFingerprint: Data,
voucheeSigningKey: Data,
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
sign: (Data) -> Data?
) -> VouchAttestation? {
guard voucheeFingerprint.count == fingerprintSize,
voucheeSigningKey.count == signingKeySize else { return nil }
let message = signableBytes(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs
)
guard let signature = sign(message), signature.count == signatureSize else { return nil }
return VouchAttestation(
voucheeFingerprint: voucheeFingerprint,
voucheeSigningKey: voucheeSigningKey,
timestampMs: timestampMs,
signature: signature
)
}
/// Verifies the Ed25519 signature against the voucher's announce-bound
/// signing key.
func verifySignature(voucherSigningKey: Data) -> Bool {
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
return false
}
return publicKey.isValidSignature(signature, for: signableBytes)
}
/// Whether the attestation is outside its validity window (older than
/// `maxAge`, or timestamped implausibly far in the future).
func isExpired(now: Date = Date()) -> Bool {
let age = now.timeIntervalSince(timestamp)
return age > Self.maxAge || age < -Self.maxClockSkew
}
// MARK: - Encoding
func encode() -> Data? {
guard voucheeFingerprint.count == Self.fingerprintSize,
voucheeSigningKey.count == Self.signingKeySize,
signature.count == Self.signatureSize else { return nil }
var data = Data()
func appendTLV(_ type: TLVType, _ value: Data) {
data.append(type.rawValue)
data.append(UInt8(value.count))
data.append(value)
}
appendTLV(.voucheeFingerprint, voucheeFingerprint)
appendTLV(.voucheeSigningKey, voucheeSigningKey)
var timestampBE = timestampMs.bigEndian
appendTLV(.timestamp, withUnsafeBytes(of: &timestampBE) { Data($0) })
appendTLV(.signature, signature)
return data
}
static func decode(from data: Data) -> VouchAttestation? {
var fingerprint: Data?
var signingKey: Data?
var timestampMs: UInt64?
var signature: Data?
var offset = data.startIndex
while offset < data.endIndex {
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
offset + 1 < data.endIndex else { return nil }
let type = data[offset]
let length = Int(data[offset + 1])
let valueStart = offset + 2
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
return nil
}
let value = Data(data[valueStart..<valueEnd])
switch TLVType(rawValue: type) {
case .voucheeFingerprint:
guard value.count == fingerprintSize else { return nil }
fingerprint = value
case .voucheeSigningKey:
guard value.count == signingKeySize else { return nil }
signingKey = value
case .timestamp:
guard value.count == 8 else { return nil }
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .signature:
guard value.count == signatureSize else { return nil }
signature = value
case nil:
break // Unknown TLV: skip for forward compatibility.
}
offset = valueEnd
}
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
return VouchAttestation(
voucheeFingerprint: fingerprint,
voucheeSigningKey: signingKey,
timestampMs: timestampMs,
signature: signature
)
}
// MARK: - Batch encoding
/// Encodes up to `maxBatchCount` attestations into one payload body.
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
var data = Data()
data.append(UInt8(attestations.count))
for attestation in attestations {
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
var lengthBE = UInt16(encoded.count).bigEndian
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
data.append(encoded)
}
return data
}
/// Decodes a batch payload, dropping malformed entries and ignoring
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
static func decodeList(from data: Data) -> [VouchAttestation] {
guard data.count > 1 else { return [] }
let declaredCount = Int(data[data.startIndex])
let limit = min(declaredCount, maxBatchCount)
var attestations: [VouchAttestation] = []
var offset = data.startIndex + 1
while attestations.count < limit, offset < data.endIndex {
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
attestations.append(attestation)
}
offset = entryEnd
}
return attestations
}
}
+12
View File
@@ -1329,6 +1329,18 @@ final class BLEService: NSObject {
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
sendNoisePayload(payload, to: peerID)
}
// MARK: Vouching over Noise
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
// Appends to the encryption service's handler array, so this never
// displaces the callbacks installed by installNoiseSessionCallbacks.
noiseService.addOnPeerAuthenticatedHandler(handler)
}
}
// MARK: - GossipSyncManager Delegate
+15
View File
@@ -132,6 +132,18 @@ protocol Transport: AnyObject {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Vouching / transitive verification (optional for transports)
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
/// `installNoiseSessionCallbacks` this never touches the (single-slot)
/// handshake-required callback, so secondary features can observe
/// session establishment without disturbing the primary registration.
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
@@ -157,6 +169,9 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
@@ -293,6 +293,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// wipe fires at a nondeterministic time racing tests that
// write media there (see the same guard in panicClearAllData).
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
@@ -71,6 +71,7 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Verification payloads
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
func handleVouchPayload(from peerID: PeerID, payload: Data)
}
extension ChatViewModel: ChatTransportEventContext {
@@ -129,6 +130,10 @@ extension ChatViewModel: ChatTransportEventContext {
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
}
func handleVouchPayload(from peerID: PeerID, payload: Data) {
vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
}
}
final class ChatTransportEventCoordinator {
@@ -371,6 +376,9 @@ private extension ChatTransportEventCoordinator {
case .verifyResponse:
context.handleVerifyResponsePayload(from: peerID, payload: payload)
case .vouch:
context.handleVouchPayload(from: peerID, payload: payload)
}
}
@@ -24,6 +24,10 @@ protocol ChatVerificationContext: AnyObject {
func setStoredVerified(_ fingerprint: String, verified: Bool)
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func saveIdentityState()
/// After a fingerprint becomes verified, run a transitive-vouch pass over
/// currently connected peers (so verifying a peer you're already connected
/// to sends vouches immediately, and the new identity propagates onward).
func vouchToConnectedVerifiedPeers()
// MARK: Encryption status
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
@@ -86,6 +90,10 @@ extension ChatViewModel: ChatVerificationContext {
peerIdentityStore.setVerified(fingerprint, verified: verified)
}
func vouchToConnectedVerifiedPeers() {
vouchCoordinator.vouchToConnectedVerifiedPeers()
}
var unifiedPeers: [BitchatPeer] {
unifiedPeerService.peers
}
@@ -148,6 +156,9 @@ final class ChatVerificationCoordinator {
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
context.updateEncryptionStatus(for: peerID)
// Verifying a peer is a vouch trigger: push attestations to my other
// connected verified peers (and to this one if already connected).
context.vouchToConnectedVerifiedPeers()
}
func unverifyFingerprint(for peerID: PeerID) {
@@ -340,6 +351,8 @@ final class ChatVerificationCoordinator {
}
context.updateEncryptionStatus(for: peerID)
// QR verification just completed same vouch trigger as manual verify.
context.vouchToConnectedVerifiedPeers()
}
}
+22
View File
@@ -177,6 +177,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
// Computed properties for compatibility
@MainActor
@@ -1280,6 +1281,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
// utility-priority wipe fires at a nondeterministic time
// deleting media that concurrently running tests (e.g. the
// sendImage flow) just wrote there, and the developer's real
// app data with it.
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
@@ -1492,6 +1500,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func setupNoiseCallbacks() {
verificationCoordinator.setupNoiseCallbacks()
vouchCoordinator.setupNoiseCallbacks()
}
/// Whether the fingerprint currently counts as vouched (1 valid vouch
/// from a voucher I verified, and no explicit verification of mine).
@MainActor
func isVouchedFingerprint(_ fingerprint: String) -> Bool {
identityManager.isVouched(fingerprint: fingerprint)
}
// MARK: - BitchatDelegate Methods
@@ -1589,6 +1605,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func didUpdatePeerList(_ peers: [PeerID]) {
peerListCoordinator.didUpdatePeerList(peers)
// A peer-list update follows every verified announce, which is where a
// peer's `.vouch` capability actually arrives retry vouching now that
// capabilities may finally be known (closes the auth-time capability race).
Task { @MainActor [weak self] in
self?.vouchCoordinator.peersUpdated(peers)
}
}
@MainActor
@@ -0,0 +1,272 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatVouchCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatVouchContext: AnyObject {
// MARK: Identity & trust state
func getFingerprint(for peerID: PeerID) -> String?
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
/// The peer's announce-bound Ed25519 signing key, if known this session.
func signingKey(forFingerprint fingerprint: String) -> Data?
/// Verified fingerprints ordered most recently verified first.
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
/// Stores an accepted vouch (identity manager enforces the storage gates).
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
// MARK: Transport
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities
/// PeerIDs with a currently established mesh session (used to run a vouch
/// pass over peers we are already connected to when we verify someone).
func connectedPeerIDs() -> [PeerID]
/// Appends a session-established observer (additive; never displaces the
/// verification coordinator's callbacks).
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
/// Signs `data` with our Noise (Ed25519) signing key.
func noiseSignData(_ data: Data) -> Data?
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
// MARK: UI refresh
/// Signals that derived trust state changed so peer list / fingerprint
/// views recompute badges.
func notifyPeerTrustChanged()
}
extension ChatViewModel: ChatVouchContext {
// `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared
// requirements with the verification context and satisfied by existing
// `ChatViewModel` members. The members below flatten nested service
// accesses into intent-named calls.
func signingKey(forFingerprint fingerprint: String) -> Data? {
identityManager.signingPublicKey(forFingerprint: fingerprint)
}
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint)
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
identityManager.recordVouch(
voucheeFingerprint: voucheeFingerprint,
voucherFingerprint: voucherFingerprint,
timestamp: timestamp
)
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
identityManager.lastVouchBatchSent(to: fingerprint)
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
identityManager.markVouchBatchSent(to: fingerprint, at: date)
}
func peerCapabilities(for peerID: PeerID) -> PeerCapabilities {
meshService.peerCapabilities(peerID)
}
func connectedPeerIDs() -> [PeerID] {
Array(connectedPeers)
}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
meshService.addPeerAuthenticatedObserver(handler)
}
func noiseSignData(_ data: Data) -> Data? {
meshService.noiseSignData(data)
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
meshService.sendVouchAttestations(payload, to: peerID)
}
func notifyPeerTrustChanged() {
// PeerListModel refreshes on this notification; the view-model change
// covers FingerprintView / VerificationModel consumers.
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
notifyUIChanged()
}
}
/// Transitive verification ("vouching"): when a Noise session comes up with a
/// peer I verified, I attest over that authenticated, encrypted session
/// to the other identities I have verified. Receivers accept such vouches
/// only from peers *they* verified, giving a serverless
/// verified-by-people-you-verified tier (`TrustLevel.vouched`).
@MainActor
final class ChatVouchCoordinator {
/// Minimum spacing between vouch batches to the same peer (persisted).
static let batchInterval: TimeInterval = 24 * 60 * 60
private unowned let context: any ChatVouchContext
init(context: any ChatVouchContext) {
self.context = context
}
/// Registers the session-established hook. Additive alongside the
/// verification coordinator's callbacks; call once at bootstrap.
func setupNoiseCallbacks() {
context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in
DispatchQueue.main.async { [weak self] in
self?.peerAuthenticated(peerID, fingerprint: fingerprint)
}
}
}
/// Trigger session established: on a Noise session coming up with a peer
/// I verified, attempt to send a vouch batch. Kept as the historical entry
/// point; the real work lives in `attemptVouch`.
func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
}
/// Trigger verified announce processed: a peer's `.vouch` capability
/// arrives on its *announce*, which is handled independently of the Noise
/// handshake. This is invoked on every peer-list update (fired after each
/// verified announce), so it closes the capability race the batch that
/// `peerAuthenticated` couldn't send (capabilities not yet known) goes out
/// once the capability-bearing announce lands. Throttled per peer.
func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) {
for peerID in peerIDs {
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
}
}
/// Trigger local verification completed: the user just verified a peer.
/// Run a vouch pass over every currently connected peer I verified. This
/// makes vouching fire when verifying someone already connected (whose
/// session is authenticated, so `peerAuthenticated` never re-fires), and it
/// propagates the newly-verified identity to my other verified peers.
/// Throttled per peer by `batchInterval`, so it can't spam.
func vouchToConnectedVerifiedPeers(now: Date = Date()) {
var sentCount = 0
for peerID in context.connectedPeerIDs() {
guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) {
sentCount += 1
}
}
if sentCount > 0 {
SecureLogger.info(
"🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)",
category: .security
)
}
}
/// Exchange policy shared by every trigger: to a peer I verified, send
/// attestations for up to `VouchAttestation.maxBatchCount` *other* verified
/// fingerprints (most recently verified first), at most once per peer per
/// `batchInterval`. Returns whether a batch was actually sent.
@discardableResult
func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool {
guard context.isVerifiedFingerprint(fingerprint) else { return false }
// Capability gate, race-tolerant: a peer's `.vouch` bit is carried on
// its announce, processed independently of the Noise handshake, so at
// authentication time the capability set is frequently still empty.
// Treat an empty/unknown set as eligible the payload is a Noise
// `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly
// ignore, so sending on an unknown set is safe and avoids the race
// dropping the batch. Only skip when the peer advertised a non-empty
// capability set that explicitly lacks `.vouch`.
let capabilities = context.peerCapabilities(for: peerID)
if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false }
if let lastSent = context.lastVouchBatchSent(to: fingerprint),
now.timeIntervalSince(lastSent) < Self.batchInterval {
return false
}
let candidates = context.recentlyVerifiedFingerprints(
limit: VouchAttestation.maxBatchCount,
excluding: fingerprint
)
var attestations: [VouchAttestation] = []
for candidate in candidates {
// Only fingerprints whose announce-bound signing key we know can
// be anchored to a concrete identity; skip the rest.
guard let fingerprintData = Data(hexString: candidate),
fingerprintData.count == VouchAttestation.fingerprintSize,
let signingKey = context.signingKey(forFingerprint: candidate),
signingKey.count == VouchAttestation.signingKeySize,
let attestation = VouchAttestation.build(
voucheeFingerprint: fingerprintData,
voucheeSigningKey: signingKey,
timestampMs: UInt64(now.timeIntervalSince1970 * 1000),
sign: context.noiseSignData
) else {
continue
}
attestations.append(attestation)
}
guard !attestations.isEmpty,
let payload = VouchAttestation.encodeList(attestations) else { return false }
context.sendVouchAttestations(payload, to: peerID)
context.markVouchBatchSent(to: fingerprint, at: now)
SecureLogger.debug(
"🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))",
category: .security
)
return true
}
/// Accept policy: process inbound vouches only from a sender I verified,
/// only with a valid Ed25519 signature under the sender's announce-bound
/// signing key, and only within the validity window. Self-vouches and
/// vouches for already-verified peers are dropped by the identity
/// manager's storage gates.
func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) {
guard let senderFingerprint = context.getFingerprint(for: peerID),
context.isVerifiedFingerprint(senderFingerprint) else {
SecureLogger.debug(
"🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))",
category: .security
)
return
}
guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else {
SecureLogger.debug(
"🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch",
category: .security
)
return
}
var acceptedCount = 0
for attestation in VouchAttestation.decodeList(from: payload) {
guard attestation.verifySignature(voucherSigningKey: senderSigningKey),
!attestation.isExpired(now: now) else { continue }
let stored = context.recordVouch(
voucheeFingerprint: attestation.voucheeFingerprintHex,
voucherFingerprint: senderFingerprint,
timestamp: attestation.timestamp
)
if stored { acceptedCount += 1 }
}
if acceptedCount > 0 {
SecureLogger.info(
"🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))",
category: .security
)
context.notifyPeerTrustChanged()
}
}
}
@@ -303,7 +303,7 @@ final class NostrInboundPipeline {
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
case .verifyChallenge, .verifyResponse, .vouch:
break
}
}
@@ -355,7 +355,7 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
case .verifyChallenge, .verifyResponse, .vouch:
break
}
}
@@ -434,7 +434,7 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
case .verifyChallenge, .verifyResponse, .vouch:
break
}
}
+43
View File
@@ -37,6 +37,14 @@ struct FingerprintView: View {
}
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched"
static func vouchedBy(_ count: Int) -> String {
String(
format: String(localized: "fingerprint.message.vouched_by", comment: "How many people the user verified have vouched for this peer"),
locale: .current,
count
)
}
static func unknownPeer() -> String {
String(localized: "common.unknown", comment: "Label for an unknown peer")
}
@@ -146,6 +154,41 @@ struct FingerprintView: View {
}
}
// Vouched (transitively verified) status: shown whenever the
// peer isn't explicitly verified but people I verified vouch
// for them, independent of the current session state.
if fingerprintState.isVouched && !fingerprintState.isVerified {
VStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal")
.font(.bitchatSystem(size: 14))
.foregroundColor(.teal)
Text(Strings.vouchedBadge)
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.teal)
}
.frame(maxWidth: .infinity)
Text(Strings.vouchedBy(fingerprintState.voucherCount))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
if !fingerprintState.voucherNames.isEmpty {
Text(fingerprintState.voucherNames.joined(separator: ", "))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
}
}
.padding(.top, 8)
.accessibilityElement(children: .combine)
}
// Verification status
if fingerprintState.canToggleVerification {
VStack(spacing: 12) {
+13
View File
@@ -25,6 +25,8 @@ struct MeshPeerList: View {
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified")
static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
@@ -134,6 +136,16 @@ struct MeshPeerList: View {
.foregroundColor(baseColor)
}
}
// Vouched (transitively verified): unfilled seal,
// deliberately distinct from verified's filled one.
// Never shown alongside a verified badge.
if peer.showsVouchedBadge {
Image(systemName: "checkmark.seal")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.vouchedTooltip)
}
}
Spacer()
@@ -241,6 +253,7 @@ struct MeshPeerList: View {
parts.append(Strings.offline)
}
}
if peer.showsVouchedBadge { parts.append(Strings.vouched) }
if peer.isFavorite { parts.append(Strings.favorite) }
if peer.hasUnread { parts.append(Strings.unread) }
if peer.isBlocked { parts.append(Strings.blocked) }
+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)
}
}
@@ -115,4 +115,47 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
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] {
[]
}
}