mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Merge remote-tracking branch 'origin/feat/vouching' into feat/integration-all
# Conflicts: # bitchat/Protocols/PeerCapabilities+Local.swift # bitchat/ViewModels/ChatViewModel.swift
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1339,6 +1339,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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -591,6 +591,45 @@ struct AppArchitectureTests {
|
||||
#expect(!verificationModel.isVerified(peerID: peerID))
|
||||
}
|
||||
|
||||
@Test("VerificationModel refreshes when peer trust changes (vouch accepted)")
|
||||
@MainActor
|
||||
func verificationModelRefreshesOnPeerTrustChange() async {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
var privateConversationModel: PrivateConversationModel? = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversations: viewModel.conversations,
|
||||
locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager())
|
||||
)
|
||||
let verificationModel = VerificationModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel!
|
||||
)
|
||||
|
||||
// PrivateConversationModel happens to observe the same notification
|
||||
// and re-assign its published selection, which would ripple into
|
||||
// VerificationModel; release it so this test pins VerificationModel's
|
||||
// own subscription rather than that incidental chain.
|
||||
privateConversationModel = nil
|
||||
|
||||
// The bound @Published sources replay their current values on
|
||||
// subscription; let those initial main-queue emissions settle so the
|
||||
// sink below observes only the trust-change signal.
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted
|
||||
// vouches via "peerStatusUpdated"; an open fingerprint sheet must
|
||||
// re-render its vouched badge from that signal alone.
|
||||
var refreshed = false
|
||||
let cancellable = verificationModel.objectWillChange.sink { _ in
|
||||
refreshed = true
|
||||
}
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
|
||||
await waitUntil { refreshed }
|
||||
#expect(refreshed)
|
||||
}
|
||||
|
||||
@Test("PeerListModel publishes mesh and geohash directory state")
|
||||
@MainActor
|
||||
func peerListModelPublishesDirectoryState() async {
|
||||
|
||||
@@ -156,6 +156,12 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
|
||||
verifyResponsePayloads.append((peerID, payload))
|
||||
}
|
||||
|
||||
private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
|
||||
|
||||
func handleVouchPayload(from peerID: PeerID, payload: Data) {
|
||||
vouchPayloads.append((peerID, payload))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -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<String> = []
|
||||
var signingKeysByFingerprint: [String: Data] = [:]
|
||||
var recentVerified: [String] = []
|
||||
private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = []
|
||||
private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = []
|
||||
var recordVouchResult = true
|
||||
var lastBatchSentAt: [String: Date] = [:]
|
||||
private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = []
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
|
||||
func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) }
|
||||
func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] }
|
||||
|
||||
func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||
recentVerifiedRequests.append((limit, fingerprint))
|
||||
return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||
recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp))
|
||||
return recordVouchResult
|
||||
}
|
||||
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] }
|
||||
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
||||
markedBatchSent.append((fingerprint, date))
|
||||
lastBatchSentAt[fingerprint] = date
|
||||
}
|
||||
|
||||
// Transport
|
||||
var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:]
|
||||
var mySigningKey = Curve25519.Signing.PrivateKey()
|
||||
private(set) var installedObservers: [(PeerID, String) -> Void] = []
|
||||
private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -107,12 +107,55 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {}
|
||||
|
||||
|
||||
func isVerified(fingerprint: String) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
func getVerifiedFingerprints() -> Set<String> {
|
||||
Set()
|
||||
}
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
|
||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||
private var vouchBatchSentAt: [String: Date] = [:]
|
||||
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||
guard voucheeFingerprint != voucherFingerprint else { return false }
|
||||
var records = vouchesByVouchee[voucheeFingerprint] ?? []
|
||||
records.removeAll { $0.voucherFingerprint == voucherFingerprint }
|
||||
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
|
||||
vouchesByVouchee[voucheeFingerprint] = records
|
||||
return true
|
||||
}
|
||||
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
||||
vouchesByVouchee[fingerprint] ?? []
|
||||
}
|
||||
|
||||
func isVouched(fingerprint: String) -> Bool {
|
||||
!(vouchesByVouchee[fingerprint] ?? []).isEmpty
|
||||
}
|
||||
|
||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
||||
socialIdentities[fingerprint]?.trustLevel ?? .unknown
|
||||
}
|
||||
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
||||
vouchBatchSentAt[fingerprint]
|
||||
}
|
||||
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
||||
vouchBatchSentAt[fingerprint] = date
|
||||
}
|
||||
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
nil
|
||||
}
|
||||
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
struct VouchAttestationTests {
|
||||
private let voucherKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
private func makeAttestation(
|
||||
fingerprint: Data = Data(repeating: 0xAA, count: 32),
|
||||
signingKey: Data = Data(repeating: 0xBB, count: 32),
|
||||
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
signedBy key: Curve25519.Signing.PrivateKey? = nil
|
||||
) throws -> VouchAttestation {
|
||||
let signer = key ?? voucherKey
|
||||
return try #require(
|
||||
VouchAttestation.build(
|
||||
voucheeFingerprint: fingerprint,
|
||||
voucheeSigningKey: signingKey,
|
||||
timestampMs: timestampMs,
|
||||
sign: { try? signer.signature(for: $0) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func roundTripsAndVerifiesSignature() throws {
|
||||
let attestation = try makeAttestation()
|
||||
let encoded = try #require(attestation.encode())
|
||||
let decoded = try #require(VouchAttestation.decode(from: encoded))
|
||||
|
||||
#expect(decoded == attestation)
|
||||
#expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32))
|
||||
#expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws {
|
||||
let attestation = try makeAttestation()
|
||||
var encoded = try #require(attestation.encode())
|
||||
|
||||
// Unknown TLV appended: skipped for forward compatibility.
|
||||
encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02])
|
||||
#expect(VouchAttestation.decode(from: encoded) == attestation)
|
||||
|
||||
// Truncation and missing fields are rejected.
|
||||
#expect(VouchAttestation.decode(from: encoded.dropLast()) == nil)
|
||||
#expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil)
|
||||
#expect(VouchAttestation.decode(from: Data()) == nil)
|
||||
|
||||
// Wrong field sizes are rejected.
|
||||
var wrongSize = Data([0x01, 0x10])
|
||||
wrongSize.append(Data(repeating: 0xAA, count: 16))
|
||||
#expect(VouchAttestation.decode(from: wrongSize) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func buildRejectsWrongKeyAndFingerprintSizes() {
|
||||
let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) }
|
||||
#expect(VouchAttestation.build(
|
||||
voucheeFingerprint: Data(repeating: 0xAA, count: 16),
|
||||
voucheeSigningKey: Data(repeating: 0xBB, count: 32),
|
||||
sign: sign
|
||||
) == nil)
|
||||
#expect(VouchAttestation.build(
|
||||
voucheeFingerprint: Data(repeating: 0xAA, count: 32),
|
||||
voucheeSigningKey: Data(repeating: 0xBB, count: 16),
|
||||
sign: sign
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forgedSignatureFailsVerification() throws {
|
||||
let attestation = try makeAttestation()
|
||||
let otherKey = Curve25519.Signing.PrivateKey()
|
||||
|
||||
// Verifying against a key that didn't sign fails.
|
||||
#expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation))
|
||||
|
||||
// An attestation signed by an imposter fails against the real key.
|
||||
let forged = try makeAttestation(signedBy: otherKey)
|
||||
#expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
|
||||
#expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func tamperedFieldsFailVerification() throws {
|
||||
let attestation = try makeAttestation()
|
||||
let publicKey = voucherKey.publicKey.rawRepresentation
|
||||
|
||||
var tamperedFingerprint = attestation.voucheeFingerprint
|
||||
tamperedFingerprint[0] ^= 0xFF
|
||||
let tampered = VouchAttestation(
|
||||
voucheeFingerprint: tamperedFingerprint,
|
||||
voucheeSigningKey: attestation.voucheeSigningKey,
|
||||
timestampMs: attestation.timestampMs,
|
||||
signature: attestation.signature
|
||||
)
|
||||
#expect(!tampered.verifySignature(voucherSigningKey: publicKey))
|
||||
|
||||
let backdated = VouchAttestation(
|
||||
voucheeFingerprint: attestation.voucheeFingerprint,
|
||||
voucheeSigningKey: attestation.voucheeSigningKey,
|
||||
timestampMs: attestation.timestampMs - 1,
|
||||
signature: attestation.signature
|
||||
)
|
||||
#expect(!backdated.verifySignature(voucherSigningKey: publicKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiryWindowIsEnforced() throws {
|
||||
let now = Date()
|
||||
let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000))
|
||||
#expect(!fresh.isExpired(now: now))
|
||||
|
||||
let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60)
|
||||
let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000))
|
||||
#expect(expired.isExpired(now: now))
|
||||
|
||||
let farFuture = now.addingTimeInterval(2 * 60 * 60)
|
||||
let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000))
|
||||
#expect(fromTheFuture.isExpired(now: now))
|
||||
|
||||
// A verified-but-expired attestation still has a valid signature; the
|
||||
// two checks are independent gates.
|
||||
#expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
|
||||
}
|
||||
|
||||
@Test
|
||||
func batchRoundTripsAndEnforcesCap() throws {
|
||||
let attestations = try (0..<3).map { index in
|
||||
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
|
||||
}
|
||||
let payload = try #require(VouchAttestation.encodeList(attestations))
|
||||
#expect(VouchAttestation.decodeList(from: payload) == attestations)
|
||||
|
||||
#expect(VouchAttestation.encodeList([]) == nil)
|
||||
|
||||
let tooMany = try (0..<17).map { index in
|
||||
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
|
||||
}
|
||||
#expect(VouchAttestation.encodeList(tooMany) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws {
|
||||
let attestations = try (0..<17).map { index in
|
||||
try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
|
||||
}
|
||||
// Hand-build an oversized batch that lies about its count.
|
||||
var payload = Data([UInt8(attestations.count)])
|
||||
for attestation in attestations {
|
||||
let encoded = try #require(attestation.encode())
|
||||
payload.append(UInt8(encoded.count >> 8))
|
||||
payload.append(UInt8(encoded.count & 0xFF))
|
||||
payload.append(encoded)
|
||||
}
|
||||
let decoded = VouchAttestation.decodeList(from: payload)
|
||||
#expect(decoded.count == VouchAttestation.maxBatchCount)
|
||||
#expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount)))
|
||||
|
||||
// A malformed middle entry is dropped without killing the batch.
|
||||
let good = try makeAttestation()
|
||||
let goodEncoded = try #require(good.encode())
|
||||
var mixed = Data([2])
|
||||
mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE])
|
||||
mixed.append(UInt8(goodEncoded.count >> 8))
|
||||
mixed.append(UInt8(goodEncoded.count & 0xFF))
|
||||
mixed.append(goodEncoded)
|
||||
#expect(VouchAttestation.decodeList(from: mixed) == [good])
|
||||
|
||||
#expect(VouchAttestation.decodeList(from: Data()) == [])
|
||||
#expect(VouchAttestation.decodeList(from: Data([5])) == [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
/// Vouch storage, accept-policy gates, derived trust levels, and persistence
|
||||
/// compatibility for `SecureIdentityStateManager`.
|
||||
///
|
||||
/// Ordering note: mutations use barrier blocks on the manager's concurrent
|
||||
/// queue and reads use `queue.sync`, so a read submitted after a mutation
|
||||
/// always observes it — no polling needed.
|
||||
///
|
||||
/// `@MainActor` matches production (the manager's vouch API is driven by the
|
||||
/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync`
|
||||
/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift
|
||||
/// Testing runs these tests in parallel on that pool, and on CI's few-core
|
||||
/// runners every pool thread ended up parked in `queue.sync` behind a pending
|
||||
/// `queue.async(.barrier)` write that never got a dispatch worker — a
|
||||
/// process-wide deadlock (watchdog SIGKILL, exit 137).
|
||||
@MainActor
|
||||
struct SecureIdentityStateManagerVouchTests {
|
||||
private let voucher = String(repeating: "0a", count: 32)
|
||||
private let vouchee = String(repeating: "0b", count: 32)
|
||||
|
||||
private func makeManager() -> SecureIdentityStateManager {
|
||||
SecureIdentityStateManager(MockKeychain())
|
||||
}
|
||||
|
||||
// MARK: - Accept-policy gates
|
||||
|
||||
@Test
|
||||
func recordVouch_rejectsUnverifiedVoucher() {
|
||||
let manager = makeManager()
|
||||
|
||||
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
|
||||
#expect(manager.validVouchers(for: vouchee).isEmpty)
|
||||
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
#expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
|
||||
#expect(manager.validVouchers(for: vouchee).count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordVouch_ignoresSelfVouch() {
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
|
||||
#expect(!manager.recordVouch(voucheeFingerprint: voucher, voucherFingerprint: voucher, timestamp: Date()))
|
||||
#expect(manager.validVouchers(for: voucher).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordVouch_ignoresAlreadyVerifiedVouchee() {
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.setVerified(fingerprint: vouchee, verified: true)
|
||||
|
||||
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
|
||||
#expect(!manager.isVouched(fingerprint: vouchee))
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordVouch_rejectsStaleAndFarFutureTimestamps() {
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
|
||||
let stale = Date().addingTimeInterval(-31 * 24 * 60 * 60)
|
||||
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: stale))
|
||||
|
||||
let farFuture = Date().addingTimeInterval(2 * 60 * 60)
|
||||
#expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: farFuture))
|
||||
|
||||
#expect(manager.validVouchers(for: vouchee).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordVouch_capsVouchersPerVoucheeKeepingMostRecent() {
|
||||
let manager = makeManager()
|
||||
let base = Date()
|
||||
|
||||
// 9 verified vouchers vouch with strictly increasing timestamps.
|
||||
let vouchers = (0..<9).map { String(format: "%02x", $0 + 0x10) + String(repeating: "00", count: 31) }
|
||||
for (index, voucherFingerprint) in vouchers.enumerated() {
|
||||
manager.setVerified(fingerprint: voucherFingerprint, verified: true)
|
||||
let stored = manager.recordVouch(
|
||||
voucheeFingerprint: vouchee,
|
||||
voucherFingerprint: voucherFingerprint,
|
||||
timestamp: base.addingTimeInterval(TimeInterval(index)),
|
||||
now: base.addingTimeInterval(TimeInterval(index))
|
||||
)
|
||||
#expect(stored)
|
||||
}
|
||||
|
||||
let records = manager.validVouchers(for: vouchee)
|
||||
#expect(records.count == SecureIdentityStateManager.maxVouchersPerVouchee)
|
||||
// The oldest voucher fell off the end.
|
||||
#expect(!records.contains { $0.voucherFingerprint == vouchers[0] })
|
||||
#expect(records.contains { $0.voucherFingerprint == vouchers[8] })
|
||||
|
||||
// An attestation older than everything retained is not stored.
|
||||
let older = String(repeating: "0c", count: 32)
|
||||
manager.setVerified(fingerprint: older, verified: true)
|
||||
#expect(!manager.recordVouch(
|
||||
voucheeFingerprint: vouchee,
|
||||
voucherFingerprint: older,
|
||||
timestamp: base.addingTimeInterval(-1),
|
||||
now: base
|
||||
))
|
||||
|
||||
// A repeat vouch from a retained voucher refreshes, not duplicates.
|
||||
#expect(manager.recordVouch(
|
||||
voucheeFingerprint: vouchee,
|
||||
voucherFingerprint: vouchers[8],
|
||||
timestamp: base.addingTimeInterval(100),
|
||||
now: base.addingTimeInterval(100)
|
||||
))
|
||||
#expect(manager.validVouchers(for: vouchee).count == SecureIdentityStateManager.maxVouchersPerVouchee)
|
||||
}
|
||||
|
||||
// MARK: - Derived trust & invalidation
|
||||
|
||||
@Test
|
||||
func unverifyingVoucher_invalidatesTheirVouchesWithoutDeletingThem() {
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
#expect(manager.isVouched(fingerprint: vouchee))
|
||||
|
||||
// Removing my verification of the voucher retires their vouches…
|
||||
manager.setVerified(fingerprint: voucher, verified: false)
|
||||
#expect(!manager.isVouched(fingerprint: vouchee))
|
||||
#expect(manager.validVouchers(for: vouchee).isEmpty)
|
||||
|
||||
// …but the records survive: re-verifying the voucher restores them
|
||||
// (recompute on read, no cascade delete).
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
#expect(manager.isVouched(fingerprint: vouchee))
|
||||
}
|
||||
|
||||
@Test
|
||||
func validVouchers_expireAtReadTime() {
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
|
||||
let now = Date()
|
||||
let timestamp = now.addingTimeInterval(-29 * 24 * 60 * 60)
|
||||
#expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: timestamp, now: now))
|
||||
#expect(manager.isVouched(fingerprint: vouchee, now: now))
|
||||
|
||||
let twoDaysLater = now.addingTimeInterval(2 * 24 * 60 * 60)
|
||||
#expect(manager.validVouchers(for: vouchee, now: twoDaysLater).isEmpty)
|
||||
#expect(!manager.isVouched(fingerprint: vouchee, now: twoDaysLater))
|
||||
}
|
||||
|
||||
@Test
|
||||
func effectiveTrustLevel_slotsVouchedBetweenCasualAndTrusted() {
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
|
||||
// Unknown peer with a valid vouch reads as vouched.
|
||||
#expect(manager.effectiveTrustLevel(for: vouchee) == .unknown)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
#expect(manager.effectiveTrustLevel(for: vouchee) == .vouched)
|
||||
|
||||
// Explicit trust outranks a vouch.
|
||||
manager.updateSocialIdentity(SocialIdentity(
|
||||
fingerprint: vouchee,
|
||||
localPetname: nil,
|
||||
claimedNickname: "bob",
|
||||
trustLevel: .trusted,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
))
|
||||
#expect(manager.effectiveTrustLevel(for: vouchee) == .trusted)
|
||||
|
||||
// Explicit verification outranks everything.
|
||||
manager.setVerified(fingerprint: vouchee, verified: true)
|
||||
#expect(manager.effectiveTrustLevel(for: vouchee) == .verified)
|
||||
#expect(!manager.isVouched(fingerprint: vouchee))
|
||||
|
||||
// Losing the voucher downgrades vouched back to the stored level.
|
||||
manager.setVerified(fingerprint: vouchee, verified: false)
|
||||
manager.setVerified(fingerprint: voucher, verified: false)
|
||||
#expect(manager.effectiveTrustLevel(for: vouchee) == .casual)
|
||||
}
|
||||
|
||||
// MARK: - Exchange-policy state
|
||||
|
||||
@Test
|
||||
func mostRecentlyVerifiedFingerprints_ordersAndExcludes() {
|
||||
let manager = makeManager()
|
||||
let first = String(repeating: "01", count: 32)
|
||||
let second = String(repeating: "02", count: 32)
|
||||
let third = String(repeating: "03", count: 32)
|
||||
manager.setVerified(fingerprint: first, verified: true)
|
||||
manager.setVerified(fingerprint: second, verified: true)
|
||||
manager.setVerified(fingerprint: third, verified: true)
|
||||
|
||||
let ordered = manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: third)
|
||||
#expect(ordered == [second, first])
|
||||
|
||||
let limited = manager.mostRecentlyVerifiedFingerprints(limit: 1, excluding: third)
|
||||
#expect(limited == [second])
|
||||
}
|
||||
|
||||
@Test
|
||||
func vouchBatchSentAt_roundTrips() {
|
||||
let manager = makeManager()
|
||||
#expect(manager.lastVouchBatchSent(to: voucher) == nil)
|
||||
|
||||
let sentAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
manager.markVouchBatchSent(to: voucher, at: sentAt)
|
||||
#expect(manager.lastVouchBatchSent(to: voucher) == sentAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signingPublicKey_returnsAnnounceBoundKeyByFingerprint() async {
|
||||
let manager = makeManager()
|
||||
let signingKey = Data(repeating: 0x22, count: 32)
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: voucher,
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: signingKey,
|
||||
claimedNickname: nil
|
||||
)
|
||||
|
||||
let stored = await waitUntil { manager.signingPublicKey(forFingerprint: voucher) == signingKey }
|
||||
#expect(stored)
|
||||
#expect(manager.signingPublicKey(forFingerprint: vouchee) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Panic wipe
|
||||
|
||||
@Test
|
||||
func clearAllIdentityData_wipesVouchState() async {
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
manager.markVouchBatchSent(to: voucher, at: Date())
|
||||
#expect(manager.isVouched(fingerprint: vouchee))
|
||||
|
||||
manager.clearAllIdentityData()
|
||||
|
||||
let wiped = await waitUntil { !manager.isVouched(fingerprint: vouchee) }
|
||||
#expect(wiped)
|
||||
#expect(manager.validVouchers(for: vouchee).isEmpty)
|
||||
#expect(manager.lastVouchBatchSent(to: voucher) == nil)
|
||||
#expect(manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: "").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Persistence compatibility
|
||||
|
||||
@Test
|
||||
func trustLevelRawValuesAreStable() throws {
|
||||
// Raw values are what's persisted; they must never change when cases
|
||||
// are added mid-ladder.
|
||||
#expect(TrustLevel.unknown.rawValue == "unknown")
|
||||
#expect(TrustLevel.casual.rawValue == "casual")
|
||||
#expect(TrustLevel.vouched.rawValue == "vouched")
|
||||
#expect(TrustLevel.trusted.rawValue == "trusted")
|
||||
#expect(TrustLevel.verified.rawValue == "verified")
|
||||
|
||||
let legacy = Data(#"["unknown","casual","trusted","verified"]"#.utf8)
|
||||
let decoded = try JSONDecoder().decode([TrustLevel].self, from: legacy)
|
||||
#expect(decoded == [.unknown, .casual, .trusted, .verified])
|
||||
}
|
||||
|
||||
@Test
|
||||
func identityCachePersistedBeforeVouchingDecodesCleanly() throws {
|
||||
// A cache captured before the vouch fields existed must decode without
|
||||
// tripping the "unreadable cache" recovery path.
|
||||
let legacyJSON = Data("""
|
||||
{
|
||||
"socialIdentities": {},
|
||||
"nicknameIndex": {},
|
||||
"verifiedFingerprints": ["\(voucher)"],
|
||||
"lastInteractions": {},
|
||||
"blockedNostrPubkeys": [],
|
||||
"version": 1
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON)
|
||||
#expect(decoded.vouchesByVouchee == nil)
|
||||
#expect(decoded.vouchBatchSentAt == nil)
|
||||
#expect(decoded.verifiedAt == nil)
|
||||
#expect(decoded.verifiedFingerprints == [voucher])
|
||||
}
|
||||
|
||||
@Test
|
||||
func identityCacheRoundTripsVouchState() throws {
|
||||
var cache = IdentityCache()
|
||||
cache.verifiedFingerprints = [voucher]
|
||||
cache.vouchesByVouchee = [vouchee: [VouchRecord(voucherFingerprint: voucher, timestamp: Date(timeIntervalSince1970: 1_700_000_000))]]
|
||||
cache.vouchBatchSentAt = [voucher: Date(timeIntervalSince1970: 1_700_000_001)]
|
||||
cache.verifiedAt = [voucher: Date(timeIntervalSince1970: 1_700_000_002)]
|
||||
|
||||
let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache))
|
||||
#expect(decoded.vouchesByVouchee == cache.vouchesByVouchee)
|
||||
#expect(decoded.vouchBatchSentAt == cache.vouchBatchSentAt)
|
||||
#expect(decoded.verifiedAt == cache.verifiedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
func vouchStateSurvivesReload() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
let saved = await waitUntil { manager.isVouched(fingerprint: self.vouchee) }
|
||||
#expect(saved)
|
||||
manager.forceSave()
|
||||
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
#expect(reloaded.isVouched(fingerprint: vouchee))
|
||||
#expect(reloaded.validVouchers(for: vouchee).count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
}
|
||||
@@ -292,4 +292,37 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
func getVerifiedFingerprints() -> Set<String> {
|
||||
verified
|
||||
}
|
||||
|
||||
// MARK: Vouching (unused by these tests)
|
||||
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
||||
[]
|
||||
}
|
||||
|
||||
func isVouched(fingerprint: String) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
||||
verified.contains(fingerprint) ? .verified : .unknown
|
||||
}
|
||||
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
||||
nil
|
||||
}
|
||||
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date) {}
|
||||
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
nil
|
||||
}
|
||||
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user