mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 08:05:19 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
70229f0be1
commit
2360140760
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -154,7 +178,22 @@ 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,16 +561,20 @@ 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
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.trustLevel = verified ? .verified : .casual
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -77,7 +77,9 @@ 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 {
|
||||
case .privateMessage: return "privateMessage"
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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: ×tampBE) { 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: ×tampBE) { 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user