diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 38f20a1c..1846b487 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -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 ) } diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift index bf31f05f..5916a958 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -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 { diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index ad83962f..921a071a 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -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 = [] - + + // 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 } diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index fe63f871..021ea776 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -133,6 +133,17 @@ protocol SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set + + // 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] { queue.sync { cache.nicknameIndex } } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7301e613..2198615b 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -22419,6 +22419,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" : { @@ -22956,6 +22968,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" : { @@ -31924,6 +31970,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" : { @@ -32103,6 +32161,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" : { diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 4ebe6509..ec2ced61 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -75,7 +75,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" @@ -83,6 +85,7 @@ enum NoisePayloadType: UInt8 { case .delivered: return "delivered" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" + case .vouch: return "vouch" } } } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 06973dde..cc5f295c 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -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 = [.prekeys] + static let localSupported: PeerCapabilities = [.prekeys, .vouch] } diff --git a/bitchat/Protocols/VouchAttestation.swift b/bitchat/Protocols/VouchAttestation.swift new file mode 100644 index 00000000..a765a11b --- /dev/null +++ b/bitchat/Protocols/VouchAttestation.swift @@ -0,0 +1,225 @@ +// +// VouchAttestation.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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.. 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.. Void) { + // Appends to the encryption service's handler array, so this never + // displaces the callbacks installed by installNoiseSessionCallbacks. + noiseService.addOnPeerAuthenticatedHandler(handler) + } } // MARK: - GossipSyncManager Delegate diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 27c38700..04dfe90d 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -128,6 +128,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) @@ -153,6 +165,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 sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 39c8aaae..6562dca2 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -296,6 +296,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { guard !TestEnvironment.isRunningTests else { return } 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, diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 75571b2e..a24273d7 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -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) } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index be78e5a3..b62dedf0 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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 @@ -1495,6 +1496,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 diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift new file mode 100644 index 00000000..fc13e07f --- /dev/null +++ b/bitchat/ViewModels/ChatVouchCoordinator.swift @@ -0,0 +1,210 @@ +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 + /// 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 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) + } + } + } + + /// Exchange policy: on session establishment with a peer I verified that + /// advertises the `.vouch` capability, send attestations for up to + /// `VouchAttestation.maxBatchCount` *other* verified fingerprints (most + /// recently verified first), at most once per peer per `batchInterval`. + func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) { + guard context.isVerifiedFingerprint(fingerprint) else { return } + guard context.peerCapabilities(for: peerID).contains(.vouch) else { return } + if let lastSent = context.lastVouchBatchSent(to: fingerprint), + now.timeIntervalSince(lastSent) < Self.batchInterval { + return + } + + 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 } + 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 + ) + } + + /// 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() + } + } +} diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 337fa474..0ebbfc7d 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -297,7 +297,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 } } @@ -349,7 +349,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 } } @@ -428,7 +428,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 } } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index 06fc865f..fb4541f5 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -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) { diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 244cd6dc..b09ef476 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -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) } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 0b70f0e4..69b7fc89 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -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 { diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 1c1fc46f..bd112e9b 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -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 diff --git a/bitchatTests/ChatVouchCoordinatorContextTests.swift b/bitchatTests/ChatVouchCoordinatorContextTests.swift new file mode 100644 index 00000000..0602b7ef --- /dev/null +++ b/bitchatTests/ChatVouchCoordinatorContextTests.swift @@ -0,0 +1,285 @@ +// +// 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 = [] + 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)] = [] + + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] } + + 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 no .vouch capability: nothing. + context.verifiedFingerprints.insert(peerFingerprint) + context.capabilitiesByPeerID[peerID] = [] + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.isEmpty) + #expect(context.markedBatchSent.isEmpty) + } + + @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) + } +} diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 633c388b..1ff1926a 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -107,12 +107,55 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) {} - + func isVerified(fingerprint: String) -> Bool { true } - + func getVerifiedFingerprints() -> Set { 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] { + [] + } } diff --git a/bitchatTests/Protocols/VouchAttestationTests.swift b/bitchatTests/Protocols/VouchAttestationTests.swift new file mode 100644 index 00000000..064cf4d8 --- /dev/null +++ b/bitchatTests/Protocols/VouchAttestationTests.swift @@ -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])) == []) + } +} diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift new file mode 100644 index 00000000..6ab46490 --- /dev/null +++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift @@ -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() + } +} diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index f5a5fd6c..172bf5b1 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -292,4 +292,37 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func getVerifiedFingerprints() -> Set { 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] { + [] + } }