mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:25:19 +00:00
When a Noise session establishes with a peer I verified and that peer advertises the .vouch capability, send signed attestations (up to 16, most recently verified first, at most once per peer per 24h) for the OTHER fingerprints I verified. Receivers accept vouches only from senders they verified themselves, verify the Ed25519 signature against the sender's announce-bound signing key, and surface the result as a new derived trust tier: vouched (unfilled seal) between casual and trusted. Protocol: - NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations: voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp (uint64 ms BE), Ed25519 signature over "bitchat-vouch-v1" | fingerprint | signingKey | timestamp. The voucher is implicit in the authenticated session. - PeerCapabilities.localSupported now advertises .vouch. Storage (SecureIdentityStateManager / IdentityCache): - vouches keyed by vouchee, capped at 8 vouchers each; validity is recomputed on read (voucher still verified-by-me, < 30 days old), so unverifying a voucher retires their vouches without cascade deletes. - New IdentityCache fields are Optional so pre-existing encrypted caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but raw values are strings, so persisted values are unaffected (and vouched itself is never persisted). - Panic wipe clears vouch state with the rest of the identity cache. UI: unfilled checkmark.seal badge in the mesh peer list (filled seal stays exclusive to verified) and a "vouched for by N people you verified" section with voucher names in FingerprintView; VoiceOver labels and xcstrings entries included. Tests: attestation encode/decode + signature (forged/tampered/expired), accept-policy gates, batch cap, trust-level derivation incl. voucher invalidation, persistence compat, and coordinator exchange/accept policies. Full macOS suite: 1088 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
286 lines
13 KiB
Swift
286 lines
13 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|