mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
Merge remote-tracking branch 'origin/feat/prekeys' into feat/integration-all
# Conflicts: # bitchat/Sync/GossipSyncManager.swift
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
//
|
||||
// PrekeyEndToEndTests.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 CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Forward-secret courier flow through real BLEService instances: Bob gossips
|
||||
/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time
|
||||
/// prekey instead of Bob's static key, Carol carries the opaque envelope, and
|
||||
/// Bob opens it with the matching prekey private.
|
||||
struct PrekeyEndToEndTests {
|
||||
|
||||
// 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 service = BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
|
||||
identityManager: MockIdentityManager(keychain),
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
service.courierStore = CourierStore(persistsToDisk: false)
|
||||
service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false)
|
||||
return service
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Broadcast announce + prekey bundle from `peer` and return both packets.
|
||||
private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) {
|
||||
peer.sendBroadcastAnnounce()
|
||||
let published = await TestHelpers.waitUntil(
|
||||
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(published)
|
||||
return (
|
||||
announce: try #require(tap.first(ofType: .announce)),
|
||||
bundle: try #require(tap.first(ofType: .prekeyBundle))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Test func prekeySealedMailTravelsViaCourierAndOpens() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||
|
||||
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
|
||||
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
// 1. While Bob is still around, Alice hears his verified announce
|
||||
// (binding his signing key) and his gossiped prekey bundle.
|
||||
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(cached)
|
||||
|
||||
// 2. Bob goes dark; Alice seals for him and deposits with Carol.
|
||||
// The envelope must be v2: sealed to a one-time prekey.
|
||||
#expect(alice.sendCourierMessage(
|
||||
"burn after reading",
|
||||
messageID: "prekey-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))
|
||||
let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload))
|
||||
#expect(sealedEnvelope.prekeyID != nil)
|
||||
|
||||
// 3. Carol carries it (opaque, prekey or not).
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
// 4. Bob resurfaces near Carol → handover, and the v2 discriminator
|
||||
// survives the store round-trip.
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload))
|
||||
#expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID)
|
||||
|
||||
// 5. Bob opens it with the matching one-time prekey private and sees
|
||||
// Alice as the authenticated sender.
|
||||
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)
|
||||
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
|
||||
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||
#expect(message.messageID == "prekey-msg-1")
|
||||
#expect(message.content == "burn after reading")
|
||||
|
||||
// 6. Redelivery tolerance: the same envelope arriving via another
|
||||
// packet (spray-and-wait) still opens inside the grace window.
|
||||
let redelivery = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: Data(hexString: carol.myPeerID.id) ?? Data(),
|
||||
recipientID: handoverPacket.recipientID,
|
||||
timestamp: handoverPacket.timestamp + 1,
|
||||
payload: handoverPacket.payload,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
|
||||
let redelivered = await TestHelpers.waitUntil(
|
||||
{ bobDelegate.snapshot().count == 2 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(redelivered)
|
||||
}
|
||||
|
||||
@Test func withoutBundleSealingFallsBackToStatic() async throws {
|
||||
let alice = makeService()
|
||||
let bob = makeService()
|
||||
|
||||
let bobDelegate = NoiseCaptureDelegate()
|
||||
bob.delegate = bobDelegate
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
|
||||
// Bob is a connected "courier" who happens to be the recipient: the
|
||||
// envelope reaches him directly and the recipient tag matches.
|
||||
preseedConnectedPeer(bob, in: alice)
|
||||
|
||||
// Alice never saw a bundle for Bob → v1 static-sealed envelope.
|
||||
#expect(alice.sendCourierMessage(
|
||||
"plain static seal",
|
||||
messageID: "static-msg-1",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [bob.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
let envelope = try #require(CourierEnvelope.decode(depositPacket.payload))
|
||||
#expect(envelope.prekeyID == nil)
|
||||
|
||||
// Bob opens the v1 envelope exactly as before the prekey change.
|
||||
// (No preseed: Alice is absent from Bob's mesh, so the sender should
|
||||
// resolve to her full noise-key ID like the courier case.)
|
||||
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(received)
|
||||
let delivered = try #require(bobDelegate.snapshot().first)
|
||||
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
|
||||
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||
#expect(message.content == "plain static seal")
|
||||
}
|
||||
|
||||
@Test func unverifiableBundleIsIgnored() async throws {
|
||||
let alice = makeService()
|
||||
let bob = makeService()
|
||||
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
// Alice receives Bob's bundle but never saw a verified announce, so
|
||||
// no signing key is bound to his noise key: the bundle must not be
|
||||
// cached or enter Alice's gossip store.
|
||||
let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
|
||||
@Test func forgedBundleSignatureIsRejected() async throws {
|
||||
let alice = makeService()
|
||||
let bob = makeService()
|
||||
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
// Mallory tampers with the gossiped bundle in flight.
|
||||
let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload))
|
||||
var forgedSignature = bundle.signature
|
||||
forgedSignature[0] ^= 0x01
|
||||
let forged = PrekeyBundle(
|
||||
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
|
||||
prekeys: bundle.prekeys,
|
||||
generatedAt: bundle.generatedAt,
|
||||
signature: forgedSignature
|
||||
)
|
||||
let forgedPacket = BitchatPacket(
|
||||
type: MessageType.prekeyBundle.rawValue,
|
||||
senderID: bundlePacket.senderID,
|
||||
recipientID: nil,
|
||||
timestamp: bundlePacket.timestamp,
|
||||
payload: try #require(forged.encode()),
|
||||
signature: bundlePacket.signature,
|
||||
ttl: bundlePacket.ttl
|
||||
)
|
||||
alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
|
||||
@Test func verifiedBundleEntersGossipStore() async throws {
|
||||
let alice = makeService()
|
||||
let bob = makeService()
|
||||
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(cached)
|
||||
// The verified bundle now participates in Alice's sync rounds.
|
||||
#expect(alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||
}
|
||||
|
||||
@Test func spoofedSenderPrekeyBundleIsRejected() async throws {
|
||||
let alice = makeService()
|
||||
let bob = makeService()
|
||||
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
// A relay re-broadcasts Bob's genuine bundle under a fabricated sender
|
||||
// ID (the DoS that would multiply cache/gossip entries and exhaust the
|
||||
// per-owner cap). Attribution is by the bundle's own key and the outer
|
||||
// signature is bound to Bob's sender ID, so the spoof is dropped — no
|
||||
// cache entry, and no gossip entry under either the fake or real ID.
|
||||
let fakeSender = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||
let spoofed = BitchatPacket(
|
||||
type: MessageType.prekeyBundle.rawValue,
|
||||
senderID: fakeSender,
|
||||
recipientID: nil,
|
||||
timestamp: bundlePacket.timestamp + 5_000,
|
||||
payload: bundlePacket.payload,
|
||||
signature: bundlePacket.signature,
|
||||
ttl: bundlePacket.ttl
|
||||
)
|
||||
alice._test_handlePacket(spoofed, fromPeerID: PeerID(hexData: fakeSender), preseedPeer: false)
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!cached)
|
||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||
#expect(!alice._test_hasGossipPrekeyBundle(for: PeerID(hexData: fakeSender)))
|
||||
}
|
||||
|
||||
@Test func replayedPrekeyBundleWithFreshTimestampIsRejected() async throws {
|
||||
let alice = makeService()
|
||||
let bob = makeService()
|
||||
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
// Rewriting the outer timestamp (to defeat the freshness window)
|
||||
// invalidates the packet signature, which covers senderID + timestamp.
|
||||
let replay = BitchatPacket(
|
||||
type: MessageType.prekeyBundle.rawValue,
|
||||
senderID: bundlePacket.senderID,
|
||||
recipientID: nil,
|
||||
timestamp: bundlePacket.timestamp + 5_000,
|
||||
payload: bundlePacket.payload,
|
||||
signature: bundlePacket.signature,
|
||||
ttl: bundlePacket.ttl
|
||||
)
|
||||
alice._test_handlePacket(replay, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!cached)
|
||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 1
|
||||
config.fragmentSyncIntervalSeconds = 1
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.prekeyBundleSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
@@ -195,19 +196,22 @@ struct GossipSyncManagerTests {
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
// One request per due schedule so each type group gets the full
|
||||
// filter capacity: publicMessages, fragment, and fileTransfer.
|
||||
// filter capacity: publicMessages, fragment, fileTransfer, and
|
||||
// prekeyBundle.
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 3)
|
||||
#expect(sentPackets.count == 4)
|
||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
||||
#expect(decoded.count == 3)
|
||||
#expect(decoded.count == 4)
|
||||
let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) }
|
||||
#expect(allTypes.contains(.announce))
|
||||
#expect(allTypes.contains(.message))
|
||||
#expect(allTypes.contains(.fragment))
|
||||
#expect(allTypes.contains(.fileTransfer))
|
||||
#expect(allTypes.contains(.prekeyBundle))
|
||||
#expect(decoded.contains { $0.types == .publicMessages })
|
||||
#expect(decoded.contains { $0.types == .fragment })
|
||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||
#expect(decoded.contains { $0.types == .prekeyBundle })
|
||||
}
|
||||
|
||||
@Test func truncatedFilterCarriesSinceCursor() throws {
|
||||
@@ -292,6 +296,7 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.prekeyBundleSyncIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
@@ -401,6 +406,7 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.prekeyBundleSyncIntervalSeconds = 0
|
||||
config.responseRateLimitMaxResponses = 1
|
||||
config.responseRateLimitWindowSeconds = 60
|
||||
|
||||
@@ -455,6 +461,7 @@ struct GossipSyncManagerTests {
|
||||
#expect(types.contains(.message))
|
||||
#expect(types.contains(.fragment))
|
||||
#expect(types.contains(.fileTransfer))
|
||||
#expect(types.contains(.prekeyBundle))
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
||||
@@ -507,6 +514,93 @@ struct GossipSyncManagerTests {
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
}
|
||||
|
||||
@Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.prekeyBundleSyncIntervalSeconds = 0
|
||||
config.stalePeerCleanupIntervalSeconds = 0
|
||||
config.stalePeerTimeoutSeconds = 5
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
// Bundles are keyed by their authenticated identity (the noise static
|
||||
// key), not the packet senderID, so the payload must be a real bundle.
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
let senderPeer = PeerID(publicKey: noiseKey)
|
||||
let sender = try #require(Data(hexString: senderPeer.id))
|
||||
let bundle = PrekeyBundle(
|
||||
noiseStaticPublicKey: noiseKey,
|
||||
prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x11, count: 32))],
|
||||
generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
signature: Data(count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
let bundlePacket = BitchatPacket(
|
||||
type: MessageType.prekeyBundle.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: try #require(bundle.encode()),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
manager.onPublicPacketSeen(bundlePacket)
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
#expect(manager._hasPrekeyBundle(for: senderPeer))
|
||||
|
||||
// Bundles outlive the owner's announce: a leave plus stale cleanup
|
||||
// must not drop them (they exist to reach offline owners).
|
||||
manager.removeAnnouncementForPeer(senderPeer)
|
||||
manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1))
|
||||
#expect(manager._hasPrekeyBundle(for: senderPeer))
|
||||
|
||||
// And a .prekeyBundle sync request is answered with the stored packet.
|
||||
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let served = try #require(delegate.packets.first)
|
||||
#expect(served.type == MessageType.prekeyBundle.rawValue)
|
||||
#expect(served.isRSR)
|
||||
}
|
||||
|
||||
@Test func prekeyBundleGossipIsKeyedByOwnerNotSenderID() {
|
||||
// One valid bundle re-broadcast under many fabricated sender IDs must
|
||||
// collapse to a single entry keyed by the bundle's own identity — the
|
||||
// spray-to-exhaust-the-cap DoS produces one entry, not N.
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager())
|
||||
let noiseKey = Data(repeating: 0xCD, count: 32)
|
||||
let ownerPeer = PeerID(publicKey: noiseKey)
|
||||
let bundle = PrekeyBundle(
|
||||
noiseStaticPublicKey: noiseKey,
|
||||
prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x22, count: 32))],
|
||||
generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
signature: Data(count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
guard let payload = bundle.encode() else { return }
|
||||
|
||||
for i in 0..<5 {
|
||||
let fakeSender = Data((0..<8).map { j in UInt8(truncatingIfNeeded: i * 31 + j) })
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.prekeyBundle.rawValue,
|
||||
senderID: fakeSender,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(i),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
manager.onPublicPacketSeen(packet)
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
// No fabricated sender ID ever creates its own entry.
|
||||
#expect(!manager._hasPrekeyBundle(for: PeerID(hexData: fakeSender)))
|
||||
}
|
||||
// Exactly the owner-keyed entry exists.
|
||||
#expect(manager._hasPrekeyBundle(for: ownerPeer))
|
||||
}
|
||||
|
||||
// MARK: - Archive persistence
|
||||
|
||||
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//
|
||||
// NoisePrekeyTests.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 CryptoKit
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys
|
||||
/// instead of the recipient's identity static key.
|
||||
struct NoisePrekeyTests {
|
||||
|
||||
@Test func sealAndOpenRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(bob.currentPrekeyBundle())
|
||||
let prekey = try #require(bundle.prekeys.first)
|
||||
|
||||
let payload = Data("meet at the north gate".utf8)
|
||||
let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
|
||||
|
||||
let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||
#expect(opened.payload == payload)
|
||||
// The X pattern authenticates the sender: Bob learns Alice's real static key.
|
||||
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
|
||||
}
|
||||
|
||||
@Test func wrongPrekeyIDCannotOpen() throws {
|
||||
// The prologue binds the ciphertext to a specific prekey ID; opening
|
||||
// with a different (existing) prekey must fail.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(bob.currentPrekeyBundle())
|
||||
#expect(bundle.prekeys.count >= 2)
|
||||
|
||||
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0])
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func unknownPrekeyIDThrows() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(bob.currentPrekeyBundle())
|
||||
let prekey = try #require(bundle.prekeys.first)
|
||||
|
||||
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
|
||||
#expect(throws: NoiseEncryptionError.unknownPrekey) {
|
||||
_ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func wrongRecipientCannotOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let carol = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobBundle = try #require(bob.currentPrekeyBundle())
|
||||
// Ensure Carol holds a prekey under the same ID as Bob's.
|
||||
_ = try #require(carol.currentPrekeyBundle())
|
||||
let prekey = try #require(bobBundle.prekeys.first)
|
||||
|
||||
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func tamperedCiphertextFailsToOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(bob.currentPrekeyBundle())
|
||||
let prekey = try #require(bundle.prekeys.first)
|
||||
|
||||
var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
|
||||
sealed[sealed.count - 1] ^= 0x01
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws {
|
||||
// Spray-and-wait can deliver the same ciphertext via several couriers
|
||||
// days apart; the consumed private survives a grace window for that.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(bob.currentPrekeyBundle())
|
||||
let prekey = try #require(bundle.prekeys.first)
|
||||
|
||||
let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey)
|
||||
let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||
let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||
#expect(first.payload == second.payload)
|
||||
}
|
||||
|
||||
@Test func prekeyAndStaticSealsAreNotInterchangeable() throws {
|
||||
// Domain-separated prologues: a static-sealed envelope must not open
|
||||
// via the prekey path and vice versa, even with matching key material.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(bob.currentPrekeyBundle())
|
||||
let prekey = try #require(bundle.prekeys.first)
|
||||
|
||||
let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id)
|
||||
}
|
||||
|
||||
let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openCourierPayload(prekeySealed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sealRejectsInvalidPrekeyPublicKey() {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32)))
|
||||
}
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sealsAreNotLinkableAcrossSends() throws {
|
||||
// Fresh ephemeral per seal even to the same prekey.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(bob.currentPrekeyBundle())
|
||||
let prekey = try #require(bundle.prekeys.first)
|
||||
let payload = Data("same message".utf8)
|
||||
|
||||
let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
|
||||
let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
|
||||
#expect(a != b)
|
||||
#expect(a.prefix(32) != b.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
/// Local one-time prekey lifecycle: batch generation, consumption, the 48h
|
||||
/// redelivery grace window, replenishment, and the panic wipe.
|
||||
struct LocalPrekeyStoreTests {
|
||||
|
||||
private final class Clock {
|
||||
var now: Date
|
||||
init(_ now: Date = Date()) { self.now = now }
|
||||
}
|
||||
|
||||
private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore {
|
||||
LocalPrekeyStore(keychain: keychain, now: { clock.now })
|
||||
}
|
||||
|
||||
private func bundle(noiseKey: Data, prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) -> PrekeyBundle {
|
||||
PrekeyBundle(
|
||||
noiseStaticPublicKey: noiseKey,
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: Data(count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func mintsFullBatchOnFirstUse() {
|
||||
let store = makeStore(clock: Clock())
|
||||
let (prekeys, generatedAt) = store.currentBundlePrekeys()
|
||||
#expect(prekeys.count == LocalPrekeyStore.Policy.batchSize)
|
||||
#expect(generatedAt > 0)
|
||||
#expect(Set(prekeys.map(\.id)).count == prekeys.count)
|
||||
}
|
||||
|
||||
@Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() {
|
||||
let clock = Clock()
|
||||
let store = makeStore(clock: clock)
|
||||
let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
|
||||
|
||||
// Consuming down to the threshold does not regenerate...
|
||||
let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold
|
||||
for prekey in initial.dropLast(keepUnconsumed) {
|
||||
store.markConsumed(prekey.id)
|
||||
}
|
||||
#expect(!store.replenishIfNeeded())
|
||||
#expect(store.unconsumedCount == keepUnconsumed)
|
||||
|
||||
// ...one more consumption does, topping back up to a full batch with
|
||||
// a newer generation stamp.
|
||||
clock.now = clock.now.addingTimeInterval(60)
|
||||
store.markConsumed(initial[initial.count - keepUnconsumed].id)
|
||||
#expect(store.replenishIfNeeded())
|
||||
let (replenished, secondGeneratedAt) = store.currentBundlePrekeys()
|
||||
#expect(replenished.count == LocalPrekeyStore.Policy.batchSize)
|
||||
#expect(secondGeneratedAt > firstGeneratedAt)
|
||||
// Surviving unconsumed prekeys stay in the fresh bundle.
|
||||
let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id))
|
||||
#expect(survivorIDs.isSubset(of: Set(replenished.map(\.id))))
|
||||
}
|
||||
|
||||
@Test func consumingAPrekeyRepublishesANewerBundlePeersAccept() {
|
||||
// Codex P1: consuming a prekey (even above the replenish threshold)
|
||||
// must republish a strictly newer bundle so a peer that cached the old
|
||||
// one replaces it and stops assigning the consumed ID before its 48h
|
||||
// grace lapses.
|
||||
let clock = Clock()
|
||||
let store = makeStore(clock: clock)
|
||||
let noiseKey = Data(repeating: 0xC0, count: 32)
|
||||
|
||||
// Owner publishes; a peer caches it and would assign the first prekey.
|
||||
let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
|
||||
let peerCache = PrekeyBundleStore(persistsToDisk: false)
|
||||
#expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: initial, generatedAt: firstGeneratedAt)))
|
||||
let consumedID = initial[0].id
|
||||
#expect(peerCache.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == consumedID)
|
||||
|
||||
// The owner opens mail sealed to that prekey: it's retired and the
|
||||
// republished bundle is strictly newer and no longer offers the ID.
|
||||
#expect(store.markConsumed(consumedID))
|
||||
let (afterConsume, secondGeneratedAt) = store.currentBundlePrekeys()
|
||||
#expect(secondGeneratedAt > firstGeneratedAt)
|
||||
#expect(!afterConsume.contains { $0.id == consumedID })
|
||||
|
||||
// The peer accepts the replacement (a same-generatedAt copy would be
|
||||
// rejected) and stops assigning the consumed ID for new mail.
|
||||
#expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: afterConsume, generatedAt: secondGeneratedAt)))
|
||||
#expect(peerCache.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id != consumedID)
|
||||
|
||||
// 48h grace: the owner can still open a redelivery of the in-flight
|
||||
// ciphertext sealed to the consumed ID until the window lapses.
|
||||
clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
|
||||
#expect(store.privateKey(for: consumedID) != nil)
|
||||
clock.now = clock.now.addingTimeInterval(120)
|
||||
#expect(store.privateKey(for: consumedID) == nil)
|
||||
}
|
||||
|
||||
@Test func consumedPrivateSurvivesGraceWindowThenDies() {
|
||||
let clock = Clock()
|
||||
let store = makeStore(clock: clock)
|
||||
let (prekeys, _) = store.currentBundlePrekeys()
|
||||
let id = prekeys[0].id
|
||||
|
||||
store.markConsumed(id)
|
||||
// Within the grace window: still retrievable for redeliveries.
|
||||
clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
|
||||
#expect(store.privateKey(for: id) != nil)
|
||||
|
||||
// Past the grace window: gone (even before replenish prunes it).
|
||||
clock.now = clock.now.addingTimeInterval(120)
|
||||
#expect(store.privateKey(for: id) == nil)
|
||||
store.replenishIfNeeded()
|
||||
#expect(store.privateKey(for: id) == nil)
|
||||
}
|
||||
|
||||
@Test func persistsAcrossInstances() {
|
||||
let keychain = MockKeychain()
|
||||
let clock = Clock()
|
||||
let first = LocalPrekeyStore(keychain: keychain, now: { clock.now })
|
||||
let (prekeys, generatedAt) = first.currentBundlePrekeys()
|
||||
first.markConsumed(prekeys[0].id)
|
||||
|
||||
let second = LocalPrekeyStore(keychain: keychain, now: { clock.now })
|
||||
let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys()
|
||||
// Consuming a prekey shrinks the published bundle, so its generation
|
||||
// stamp advances strictly (even without the clock moving) — peers must
|
||||
// see a newer bundle to replace the one that still offered the
|
||||
// consumed ID.
|
||||
#expect(reloadedGeneratedAt > generatedAt)
|
||||
#expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id)))
|
||||
// The consumed key is still openable within grace after a relaunch.
|
||||
#expect(second.privateKey(for: prekeys[0].id) != nil)
|
||||
}
|
||||
|
||||
@Test func wipeRemovesEverything() {
|
||||
let keychain = MockKeychain()
|
||||
let store = LocalPrekeyStore(keychain: keychain)
|
||||
let (prekeys, _) = store.currentBundlePrekeys()
|
||||
store.wipe()
|
||||
#expect(store.privateKey(for: prekeys[0].id) == nil)
|
||||
#expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//
|
||||
// PrekeyBundleStoreTests.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 CryptoKit
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest,
|
||||
/// per-message prekey assignment (never reused across messages), expiry, and
|
||||
/// the peer cap.
|
||||
struct PrekeyBundleStoreTests {
|
||||
|
||||
private func makeBundle(
|
||||
noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
|
||||
ids: [UInt32] = [0, 1, 2],
|
||||
generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
) -> PrekeyBundle {
|
||||
PrekeyBundle(
|
||||
noiseStaticPublicKey: noiseKey,
|
||||
prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) },
|
||||
generatedAt: generatedAt,
|
||||
signature: Data(count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func ingestKeepsLatestByGeneratedAt() {
|
||||
let store = PrekeyBundleStore(persistsToDisk: false)
|
||||
let noiseKey = Data(repeating: 0xB0, count: 32)
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)
|
||||
let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs)
|
||||
|
||||
#expect(store.ingest(new))
|
||||
// Older (and equal) bundles never displace a newer one.
|
||||
#expect(!store.ingest(old))
|
||||
#expect(!store.ingest(new))
|
||||
|
||||
let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
|
||||
#expect(assigned?.id == 2)
|
||||
}
|
||||
|
||||
@Test func assignmentsConsumeDistinctPrekeysPerMessage() {
|
||||
let store = PrekeyBundleStore(persistsToDisk: false)
|
||||
let noiseKey = Data(repeating: 0xB1, count: 32)
|
||||
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11])))
|
||||
|
||||
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
|
||||
let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
|
||||
#expect(first?.id == 10)
|
||||
#expect(second?.id == 11)
|
||||
// Exhausted: fall back to static sealing.
|
||||
#expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil)
|
||||
#expect(!store.hasUsableBundle(for: noiseKey))
|
||||
}
|
||||
|
||||
@Test func redepositOfSameMessageReusesItsPrekey() {
|
||||
let store = PrekeyBundleStore(persistsToDisk: false)
|
||||
let noiseKey = Data(repeating: 0xB2, count: 32)
|
||||
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7])))
|
||||
|
||||
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
|
||||
let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
|
||||
#expect(first?.id == retry?.id)
|
||||
#expect(first?.publicKey == retry?.publicKey)
|
||||
// Only one prekey was burned.
|
||||
let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
|
||||
#expect(next?.id == 6)
|
||||
}
|
||||
|
||||
@Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() {
|
||||
let store = PrekeyBundleStore(persistsToDisk: false)
|
||||
let noiseKey = Data(repeating: 0xB3, count: 32)
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)))
|
||||
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0)
|
||||
|
||||
// The owner topped up: ID 1 survives (still unconsumed on their side),
|
||||
// ID 0 rotated out, new IDs appear.
|
||||
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs)))
|
||||
// m1's assignment referenced a rotated-out ID; a re-deposit picks a
|
||||
// fresh one rather than sealing to a dead key.
|
||||
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
|
||||
#expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8)
|
||||
}
|
||||
|
||||
@Test func expiredBundleIsNeverUsed() {
|
||||
var current = Date()
|
||||
let store = PrekeyBundleStore(persistsToDisk: false, now: { current })
|
||||
let noiseKey = Data(repeating: 0xB4, count: 32)
|
||||
#expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
|
||||
#expect(store.hasUsableBundle(for: noiseKey))
|
||||
|
||||
current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60)
|
||||
#expect(!store.hasUsableBundle(for: noiseKey))
|
||||
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil)
|
||||
}
|
||||
|
||||
@Test func peerCapEvictsLeastRecentlyUpdated() {
|
||||
var current = Date()
|
||||
let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current })
|
||||
let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) }
|
||||
|
||||
for key in keys {
|
||||
#expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
|
||||
current = current.addingTimeInterval(1)
|
||||
}
|
||||
// Oldest entry evicted; the two most recent survive.
|
||||
#expect(!store.hasUsableBundle(for: keys[0]))
|
||||
#expect(store.hasUsableBundle(for: keys[1]))
|
||||
#expect(store.hasUsableBundle(for: keys[2]))
|
||||
}
|
||||
|
||||
@Test func persistsAcrossInstancesAndWipes() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
let fileURL = dir.appendingPathComponent("bundles.json")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let noiseKey = Data(repeating: 0xB5, count: 32)
|
||||
let first = PrekeyBundleStore(fileURL: fileURL)
|
||||
#expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2])))
|
||||
#expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
|
||||
|
||||
// Consumption state survives a relaunch, so a restart can't reuse a prekey.
|
||||
let second = PrekeyBundleStore(fileURL: fileURL)
|
||||
#expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2)
|
||||
|
||||
second.wipe()
|
||||
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||
let third = PrekeyBundleStore(fileURL: fileURL)
|
||||
#expect(!third.hasUsableBundle(for: noiseKey))
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that
|
||||
/// v1 decoders skip as unknown.
|
||||
struct CourierEnvelopeV2Tests {
|
||||
|
||||
@Test func prekeyIDRoundTrips() throws {
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength),
|
||||
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
|
||||
ciphertext: Data("ciphertext".utf8),
|
||||
copies: 4,
|
||||
prekeyID: 0xAABB_CCDD
|
||||
)
|
||||
let encoded = try #require(envelope.encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded == envelope)
|
||||
#expect(decoded.prekeyID == 0xAABB_CCDD)
|
||||
}
|
||||
|
||||
@Test func v1EnvelopeDecodesWithNilPrekeyID() throws {
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength),
|
||||
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
|
||||
ciphertext: Data("legacy".utf8)
|
||||
)
|
||||
let encoded = try #require(envelope.encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded.prekeyID == nil)
|
||||
}
|
||||
|
||||
@Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws {
|
||||
// Static-sealed envelopes must stay on the pre-prekey wire format.
|
||||
let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength)
|
||||
let expiry: UInt64 = 1_800_000_000_000
|
||||
let ciphertext = Data("same".utf8)
|
||||
let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode())
|
||||
let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode())
|
||||
#expect(v1 == v1Explicit)
|
||||
// And a v2 envelope is the v1 bytes plus one trailing TLV a v1
|
||||
// decoder skips as unknown.
|
||||
let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode())
|
||||
#expect(v2.prefix(v1.count) == v1)
|
||||
#expect(v2.count == v1.count + 3 + 4)
|
||||
}
|
||||
|
||||
@Test func withCopiesPreservesPrekeyID() {
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength),
|
||||
expiry: 1,
|
||||
ciphertext: Data([0x01]),
|
||||
copies: 4,
|
||||
prekeyID: 9
|
||||
)
|
||||
#expect(envelope.withCopies(2).prekeyID == 9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// PrekeyBundleTests.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 CryptoKit
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Wire format and signature binding for gossiped one-time prekey bundles.
|
||||
struct PrekeyBundleTests {
|
||||
|
||||
private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] {
|
||||
(0..<count).map { index in
|
||||
PrekeyBundle.Prekey(
|
||||
id: UInt32(index),
|
||||
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wire format
|
||||
|
||||
@Test func encodeDecodeRoundTrip() throws {
|
||||
let bundle = PrekeyBundle(
|
||||
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
|
||||
prekeys: makePrekeys(8),
|
||||
generatedAt: 1_234_567_890_123,
|
||||
signature: Data(repeating: 0xAB, count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
let encoded = try #require(bundle.encode())
|
||||
let decoded = try #require(PrekeyBundle.decode(encoded))
|
||||
#expect(decoded == bundle)
|
||||
}
|
||||
|
||||
@Test func decodeSkipsUnknownTLVs() throws {
|
||||
let bundle = PrekeyBundle(
|
||||
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
|
||||
prekeys: makePrekeys(2),
|
||||
generatedAt: 42,
|
||||
signature: Data(repeating: 0x01, count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
var encoded = try #require(bundle.encode())
|
||||
// Unknown TLV 0x7F appended by a future client.
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03])
|
||||
let decoded = try #require(PrekeyBundle.decode(encoded))
|
||||
#expect(decoded == bundle)
|
||||
}
|
||||
|
||||
@Test func encodeRejectsInvalidShapes() {
|
||||
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||
let signature = Data(repeating: 0, count: PrekeyBundle.signatureLength)
|
||||
// No prekeys.
|
||||
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: [], generatedAt: 1, signature: signature).encode() == nil)
|
||||
// Too many prekeys.
|
||||
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(PrekeyBundle.maxPrekeys + 1), generatedAt: 1, signature: signature).encode() == nil)
|
||||
// Wrong owner key length.
|
||||
#expect(PrekeyBundle(noiseStaticPublicKey: Data(repeating: 1, count: 8), prekeys: makePrekeys(1), generatedAt: 1, signature: signature).encode() == nil)
|
||||
// Wrong signature length.
|
||||
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(1), generatedAt: 1, signature: Data(repeating: 0, count: 32)).encode() == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsDuplicatePrekeyIDs() throws {
|
||||
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||
let prekey = makePrekeys(1)[0]
|
||||
let bundle = PrekeyBundle(
|
||||
noiseStaticPublicKey: key,
|
||||
prekeys: [prekey, PrekeyBundle.Prekey(id: prekey.id, publicKey: key)],
|
||||
generatedAt: 1,
|
||||
signature: Data(repeating: 0, count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
let encoded = try #require(bundle.encode())
|
||||
#expect(PrekeyBundle.decode(encoded) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Signature binding
|
||||
|
||||
@Test func signVerifyRoundTrip() throws {
|
||||
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(owner.currentPrekeyBundle())
|
||||
|
||||
#expect(bundle.noiseStaticPublicKey == owner.getStaticPublicKeyData())
|
||||
#expect(bundle.prekeys.count == PrekeyBundle.maxPrekeys)
|
||||
#expect(owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: owner.getSigningPublicKeyData()))
|
||||
}
|
||||
|
||||
@Test func forgedSignatureFailsVerification() throws {
|
||||
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(owner.currentPrekeyBundle())
|
||||
|
||||
var forgedSignature = bundle.signature
|
||||
forgedSignature[0] ^= 0x01
|
||||
let forged = PrekeyBundle(
|
||||
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
|
||||
prekeys: bundle.prekeys,
|
||||
generatedAt: bundle.generatedAt,
|
||||
signature: forgedSignature
|
||||
)
|
||||
#expect(!owner.verifyPrekeyBundleSignature(forged, signingPublicKey: owner.getSigningPublicKeyData()))
|
||||
}
|
||||
|
||||
@Test func tamperedContentsFailVerification() throws {
|
||||
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(owner.currentPrekeyBundle())
|
||||
|
||||
// Mallory swaps in her own prekey but keeps the owner's signature.
|
||||
var prekeys = bundle.prekeys
|
||||
prekeys[0] = PrekeyBundle.Prekey(
|
||||
id: prekeys[0].id,
|
||||
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||
)
|
||||
let tampered = PrekeyBundle(
|
||||
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
|
||||
prekeys: prekeys,
|
||||
generatedAt: bundle.generatedAt,
|
||||
signature: bundle.signature
|
||||
)
|
||||
#expect(!owner.verifyPrekeyBundleSignature(tampered, signingPublicKey: owner.getSigningPublicKeyData()))
|
||||
}
|
||||
|
||||
@Test func wrongSigningKeyFailsVerification() throws {
|
||||
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let other = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bundle = try #require(owner.currentPrekeyBundle())
|
||||
|
||||
#expect(!owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: other.getSigningPublicKeyData()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user