mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 21:05:20 +00:00
Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers
When a private message has no reachable transport, the router now seals it to the recipient's Noise static key (new one-way Noise X pattern) and hands the envelope to up to three connected mutual favorites. Couriers store the opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB, 24 h) and hand it over when the recipient's announce matches a rotating HMAC recipient tag; the recipient opens it and the message flows through the normal private-message pipeline, so dedup and delivery acks just work. - CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation - Noise X one-way pattern reusing the existing handshake machinery, domain-separated by a courier prologue; sender identity authenticated via the ss DH (no forward secrecy - documented tradeoff) - CourierStore with eviction, file persistence, and panic-wipe integration - Rotating recipient tags (HMAC over epoch day) so carried envelopes don't correlate for observers who don't already know the recipient's key - New "carried" delivery status with figure.walk glyph; header indicator while carrying mail for others - Three-node end-to-end test ferrying packets through real BLEService instances, plus codec/crypto/store/router suites (986 tests green) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
||||
// failed < sending < sent < carried < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
.carried,
|
||||
.partiallyDelivered(reached: 1, total: 3),
|
||||
.delivered(to: "B", at: Date()),
|
||||
.read(by: "C", at: Date())
|
||||
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .partiallyDelivered: #expect(index == 3)
|
||||
case .delivered: #expect(index == 4)
|
||||
case .read: #expect(index == 5)
|
||||
case .carried: #expect(index == 3)
|
||||
case .partiallyDelivered: #expect(index == 4)
|
||||
case .delivered: #expect(index == 5)
|
||||
case .read: #expect(index == 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// CourierStoreTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct CourierStoreTests {
|
||||
|
||||
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
|
||||
private func makeStore(now: Date = baseDate) -> CourierStore {
|
||||
CourierStore(persistsToDisk: false, now: { now })
|
||||
}
|
||||
|
||||
/// Store whose clock can be advanced by tests.
|
||||
private final class Clock {
|
||||
var now: Date
|
||||
init(_ now: Date) { self.now = now }
|
||||
}
|
||||
|
||||
private func makeEnvelope(
|
||||
recipientKey: Data = Data(repeating: 0xB0, count: 32),
|
||||
sealedAt: Date = baseDate,
|
||||
lifetime: TimeInterval = 60 * 60,
|
||||
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
|
||||
) -> CourierEnvelope {
|
||||
CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: sealedAt)
|
||||
),
|
||||
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
|
||||
ciphertext: ciphertext
|
||||
)
|
||||
}
|
||||
|
||||
private let depositorA = Data(repeating: 0xA1, count: 32)
|
||||
private let depositorB = Data(repeating: 0xA2, count: 32)
|
||||
|
||||
// MARK: - Deposit and handover
|
||||
|
||||
@Test func depositThenTakeForRecipient() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
let taken = store.takeEnvelopes(for: recipientKey)
|
||||
#expect(taken == [envelope])
|
||||
// Handover removes the envelope.
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
@Test func takeIgnoresOtherRecipients() {
|
||||
let store = makeStore()
|
||||
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
|
||||
store.deposit(envelope, from: depositorA)
|
||||
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
|
||||
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
|
||||
}
|
||||
|
||||
@Test func duplicateDepositIsIdempotent() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Validity
|
||||
|
||||
@Test func rejectsExpiredAndOversizedAndMalformed() {
|
||||
let store = makeStore()
|
||||
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
|
||||
#expect(!store.deposit(expired, from: depositorA))
|
||||
|
||||
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
|
||||
#expect(!store.deposit(oversized, from: depositorA))
|
||||
|
||||
let badTag = CourierEnvelope(
|
||||
recipientTag: Data(repeating: 0, count: 4),
|
||||
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
|
||||
ciphertext: Data(repeating: 1, count: 16)
|
||||
)
|
||||
#expect(!store.deposit(badTag, from: depositorA))
|
||||
}
|
||||
|
||||
@Test func rejectsExpiryBeyondPolicyLifetime() {
|
||||
let store = makeStore()
|
||||
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
|
||||
#expect(!store.deposit(pinned, from: depositorA))
|
||||
}
|
||||
|
||||
// MARK: - Quotas
|
||||
|
||||
@Test func perDepositorQuota() {
|
||||
let store = makeStore()
|
||||
for _ in 0..<CourierStore.Limits.maxPerDepositor {
|
||||
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
||||
}
|
||||
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
||||
// A different depositor still has room.
|
||||
#expect(store.deposit(makeEnvelope(), from: depositorB))
|
||||
}
|
||||
|
||||
@Test func totalQuotaEvictsOldestFirst() {
|
||||
let store = makeStore()
|
||||
let firstRecipient = Data(repeating: 0xD0, count: 32)
|
||||
let first = makeEnvelope(recipientKey: firstRecipient)
|
||||
store.deposit(first, from: depositorA)
|
||||
|
||||
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
|
||||
var deposited = 1
|
||||
var depositorByte: UInt8 = 1
|
||||
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||
let depositor = Data(repeating: depositorByte, count: 32)
|
||||
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||
#expect(store.deposit(makeEnvelope(), from: depositor))
|
||||
deposited += 1
|
||||
}
|
||||
depositorByte += 1
|
||||
}
|
||||
|
||||
// The first envelope was evicted to make room.
|
||||
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Expiry over time
|
||||
|
||||
@Test func expiredEnvelopesAreNotHandedOver() {
|
||||
let clock = Clock(Self.baseDate)
|
||||
let store = CourierStore(persistsToDisk: false, now: { clock.now })
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
|
||||
|
||||
clock.now = Self.baseDate.addingTimeInterval(7200)
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Panic wipe
|
||||
|
||||
@Test func wipeDropsEverything() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
|
||||
store.wipe()
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
@Test func persistsAndReloadsAcrossInstances() throws {
|
||||
// Uses the real on-disk location; clean up around the test.
|
||||
let first = CourierStore(persistsToDisk: true, now: { Self.baseDate })
|
||||
defer { first.wipe() }
|
||||
first.wipe()
|
||||
|
||||
let recipientKey = Data(repeating: 0xE0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(first.deposit(envelope, from: depositorA))
|
||||
|
||||
let second = CourierStore(persistsToDisk: true, now: { Self.baseDate })
|
||||
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//
|
||||
// CourierEndToEndTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Three-node courier flow exercised through real BLEService instances with
|
||||
/// packets ferried in-process: Alice deposits a sealed envelope with Carol
|
||||
/// while Bob is unreachable; Carol hands it over when Bob announces; Bob
|
||||
/// opens it and sees Alice's message in the right DM thread.
|
||||
struct CourierEndToEndTests {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private final class PacketTap {
|
||||
private let lock = NSLock()
|
||||
private var packets: [BitchatPacket] = []
|
||||
|
||||
func record(_ packet: BitchatPacket) {
|
||||
lock.lock(); packets.append(packet); lock.unlock()
|
||||
}
|
||||
|
||||
func first(ofType type: MessageType) -> BitchatPacket? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.first { $0.type == type.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
private final class NoiseCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
|
||||
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return payloads
|
||||
}
|
||||
|
||||
// Unused BitchatDelegate requirements.
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
service.courierStore = CourierStore(persistsToDisk: false)
|
||||
return service
|
||||
}
|
||||
|
||||
/// Handling any packet from a peer preseeds it as a connected,
|
||||
/// verified entry in the receiving service's registry.
|
||||
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("ping".utf8),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
// Alice and Carol are mutual favorites; trust policy is exercised
|
||||
// separately in depositFromUntrustedPeerIsRejected.
|
||||
carol.courierDepositPolicy = { _ in true }
|
||||
|
||||
let bobDelegate = NoiseCaptureDelegate()
|
||||
bob.delegate = bobDelegate
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
// Alice can see Carol; Bob is nowhere on the mesh.
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
// 1. Alice seals to Bob's static key and deposits with Carol.
|
||||
#expect(alice.sendCourierMessage(
|
||||
"the camp moved north",
|
||||
messageID: "courier-msg-1",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
// 3. Later, Bob announces near Carol → handover fires.
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(carol.courierStore.isEmpty)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
|
||||
|
||||
// 4. Ferry the handover to Bob; he opens the envelope.
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
let delivered = try #require(bobDelegate.snapshot().first)
|
||||
#expect(delivered.type == .privateMessage)
|
||||
// Sender resolves to Alice's stable mesh identity, not the courier's.
|
||||
#expect(delivered.peerID == alice.myPeerID)
|
||||
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||
#expect(message.messageID == "courier-msg-1")
|
||||
#expect(message.content == "the camp moved north")
|
||||
}
|
||||
|
||||
@Test func depositFromUntrustedPeerIsRejected() async throws {
|
||||
let carol = makeService()
|
||||
carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
|
||||
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1"))
|
||||
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
|
||||
let now = Date()
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: bobKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: now)
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
|
||||
ciphertext: sealed
|
||||
)
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: carol.myPeerID.id),
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: try #require(envelope.encode()),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID)
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Router courier selection
|
||||
|
||||
/// Minimal transport stub for exercising MessageRouter's courier deposit
|
||||
/// logic without BLE plumbing.
|
||||
private final class CourierCaptureTransport: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var eventDelegate: TransportEventDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var snapshots: [TransportPeerSnapshot] = []
|
||||
private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = []
|
||||
private(set) var directSends: [String] = []
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just(snapshots).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots }
|
||||
|
||||
var myPeerID = PeerID(str: "00000000000000aa")
|
||||
var myNickname = "stub"
|
||||
func setNickname(_ nickname: String) {}
|
||||
|
||||
func startServices() {}
|
||||
func stopServices() {}
|
||||
func emergencyDisconnectAll() {}
|
||||
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||
snapshots.contains { $0.peerID == peerID && $0.isConnected }
|
||||
}
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) }
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String]) {}
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
directSends.append(messageID)
|
||||
}
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
|
||||
func sendBroadcastAnnounce() {}
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
|
||||
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
courierSends.append((messageID, recipientNoiseKey, couriers))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageRouterCourierTests {
|
||||
|
||||
@Test @MainActor
|
||||
func unreachablePeerMessageGoesToTrustedCouriersOnly() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||
let carolID = PeerID(publicKey: carolKey)
|
||||
let daveKey = Data(repeating: 0xD0, count: 32)
|
||||
let daveID = PeerID(publicKey: daveKey)
|
||||
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
// Carol: connected mutual favorite → eligible courier.
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()),
|
||||
// Dave: connected but not trusted → never a courier.
|
||||
TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date())
|
||||
]
|
||||
|
||||
let directory = CourierDirectory(
|
||||
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||
isTrustedCourier: { $0 == carolKey }
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1")
|
||||
|
||||
#expect(transport.directSends.isEmpty)
|
||||
#expect(transport.courierSends.count == 1)
|
||||
#expect(transport.courierSends.first?.messageID == "m1")
|
||||
#expect(transport.courierSends.first?.recipientKey == bobKey)
|
||||
#expect(transport.courierSends.first?.couriers == [carolID])
|
||||
#expect(carried == ["m1"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func noCourierDepositWithoutKnownRecipientKey() {
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date())
|
||||
]
|
||||
let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true })
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2")
|
||||
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
#expect(carried.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func reachablePeerSkipsCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date())
|
||||
]
|
||||
let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true })
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
|
||||
router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3")
|
||||
|
||||
#expect(transport.directSends == ["m3"])
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// NoiseCourierTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
/// One-way Noise X envelopes: encryption to a known static key without an
|
||||
/// interactive handshake, used by the courier store-and-forward path.
|
||||
struct NoiseCourierTests {
|
||||
|
||||
@Test func sealAndOpenRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let payload = Data("meet at the north gate".utf8)
|
||||
let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
|
||||
let opened = try bob.openCourierPayload(sealed)
|
||||
#expect(opened.payload == payload)
|
||||
// The X pattern authenticates the sender: Bob learns Alice's real static key.
|
||||
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
|
||||
}
|
||||
|
||||
@Test func wrongRecipientCannotOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let carol = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try carol.openCourierPayload(sealed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func tamperedEnvelopeFailsToOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
sealed[sealed.count - 1] ^= 0x01
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openCourierPayload(sealed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func senderIdentityCannotBeForged() throws {
|
||||
// The encrypted static key inside the envelope is bound by the ss DH;
|
||||
// splicing one envelope's ephemeral prefix onto another must fail.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let bobKey = bob.getStaticPublicKeyData()
|
||||
let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||
let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||
|
||||
// e (32 bytes) from Mallory's envelope + rest from Alice's.
|
||||
let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openCourierPayload(Data(spliced))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sealRejectsInvalidRecipientKey() {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32))
|
||||
}
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func emptyAndLargePayloadsRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = bob.getStaticPublicKeyData()
|
||||
|
||||
let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey)
|
||||
#expect(try bob.openCourierPayload(empty).payload.isEmpty)
|
||||
|
||||
let large = Data((0..<8192).map { UInt8($0 % 251) })
|
||||
let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey)
|
||||
#expect(try bob.openCourierPayload(sealed).payload == large)
|
||||
}
|
||||
|
||||
@Test func envelopesAreNotLinkableAcrossSends() throws {
|
||||
// Fresh ephemeral per seal: same payload to the same recipient must
|
||||
// produce entirely different ciphertexts.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let payload = Data("same message".utf8)
|
||||
|
||||
let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
#expect(a != b)
|
||||
#expect(a.prefix(32) != b.prefix(32))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user