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