mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +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:
@@ -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