Prekey bundles: forward-secret async first contact for courier mail (#1381)

* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Prekey bundles: forward-secret async first contact for courier mail

Courier envelopes were sealed with one-way Noise X to the recipient's
long-lived static key, so a later compromise of that key exposed every
envelope captured in transit. This adds one-time prekey bundles:

- PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys
  bound to the owner's Noise static key by an Ed25519 signature over
  "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its
  own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness)
  and verified against the announce-bound signing key before caching.
- Sealed envelope v2: Noise X where the responder static is the one-time
  prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides
  encrypted inside and is authenticated exactly like v1 (blocked-sender
  check included). CourierEnvelope gains an optional prekeyID TLV that
  v1 decoders skip as unknown.
- Local prekeys live in the Keychain; consumed privates survive a 48h
  grace window for spray-and-wait redeliveries, then are deleted (the
  forward-secrecy clock starts at deletion). The batch tops back up and
  re-gossips when unconsumed count drops below 3, and everything is
  wiped in panic mode.
- Routing: courier sealing picks a cached verified bundle when one
  exists (one prekey per message, reused across deposit retries), with
  the advertised .prekeys capability as a veto for on-mesh peers, and
  falls back to static sealing otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Prekeys: authenticate bundle packets, fix consume-republish, deflake CI

Fixes the prekey-bundle PR review + CI failure:

- CI root cause: the receive queue (mesh.message) is concurrent, so a
  gossiped prekey bundle can be processed before the announce that binds
  its owner's signing key. The old handler dropped such bundles outright,
  so under CI parallel load the bundle was permanently lost and the
  cache/gossip tests flaked (verifiedBundleEntersGossipStore,
  prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before
  their binding are now retained per-owner (bounded) and re-attempted when
  the verified announce lands, atomically to avoid a check-then-act race.

- Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM):
  require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the
  packet's Ed25519 signature (covers senderID + timestamp) against the
  owner's bound signing key, in addition to the inner bundle signature.
  Stops replay under a fresh timestamp / fake senderID.

- Key the gossip prekey-bundle store/dedup by the bundle's authenticated
  identity (noiseStaticPublicKey), not the unauthenticated packet
  senderID, so one valid bundle sprayed under many fabricated sender IDs
  can't multiply entries and exhaust the 200-owner cap.

- Bump published-bundle generatedAt strictly on consume (Codex P1):
  consuming a prekey shrinks the published bundle, so it now republishes
  with a strictly newer generatedAt and re-gossips, so peers replace the
  cached copy and stop assigning the consumed ID before its 48h grace.

- Guard the panic/clear detached Application Support tree-deletes behind
  TestEnvironment.isRunningTests: the SPM test process shares that tree,
  so the wipe could land mid-test and flake file-dependent tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update sync tests for prekeyBundle as bit 9 / default sync round

Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables
a prekey sync round by default. That broke tests authored by other PRs
that assumed bit 9 was phantom or that only their own sync round fires:

- SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+
  (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned.
- GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync
  round in configs that run maintenance (as they already do for message/
  fragment/fileTransfer), so they isolate the behavior under test.

Full app suite (1301 tests) green locally via SPM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 15:38:33 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent f9032cf2b9
commit 87910541ef
23 changed files with 2254 additions and 39 deletions
@@ -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))
}
}
+99 -4
View File
@@ -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)
@@ -362,6 +367,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 +407,7 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
config.prekeyBundleSyncIntervalSeconds = 0
config.responseRateLimitMaxResponses = 1
config.responseRateLimitWindowSeconds = 60
@@ -455,6 +462,7 @@ struct GossipSyncManagerTests {
#expect(types.contains(.message))
#expect(types.contains(.fragment))
#expect(types.contains(.fileTransfer))
#expect(types.contains(.prekeyBundle))
}
@Test func handleRequestSyncHonorsTypeFilter() async throws {
@@ -533,6 +541,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)
@@ -579,7 +588,6 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
@@ -599,6 +607,93 @@ struct GossipSyncManagerTests {
#expect(ids == Set([stalledID]))
}
@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 {
+283
View File
@@ -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()))
}
}
@@ -34,6 +34,7 @@ struct GossipSyncBoardTests {
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
config.prekeyBundleSyncIntervalSeconds = 0
return config
}
@@ -38,18 +38,19 @@ struct SyncTypeFlagsBoardTests {
/// decode path accepts the bytes and simply maps unknown bits to no
/// message type, so a board-only request reads as "nothing I can serve".
@Test func unknownBitsDecodeToNoTypes() throws {
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
// must decode without error and yield no known types.
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
// Bits 10-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle); a
// future (or unknown) two-byte bitfield must decode without error and
// yield no known types.
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFC])))
#expect(decoded.toMessageTypes().isEmpty)
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle] {
#expect(!decoded.contains(type))
}
}
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
// Known low-byte flags survive alongside unknown high bits.
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
// Known low-byte flags survive alongside unknown high bits (10-15).
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC])))
#expect(decoded.contains(.announce))
#expect(decoded.contains(.message))
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
+8 -7
View File
@@ -13,9 +13,9 @@ struct SyncTypeFlagsTests {
}
@Test func decodeDropsPhantomBits() {
// Bits 9+ map to no message type (bit 8 is boardPost). They must not
// survive decode as phantom membership.
let phantom = Data([0x00, 0xFE]) // bits 9..15 set, no known type
// Bits 10+ map to no message type (bit 8 = boardPost, bit 9 =
// prekeyBundle). They must not survive decode as phantom membership.
let phantom = Data([0x00, 0xFC]) // bits 10..15 set, no known type
let decoded = SyncTypeFlags.decode(phantom)
#expect(decoded?.rawValue == 0)
#expect(decoded?.toMessageTypes().isEmpty == true)
@@ -23,16 +23,17 @@ struct SyncTypeFlagsTests {
@Test func boardBitSurvivesDecode() {
// Bit 8 maps to boardPost and spills the field into a second byte;
// it must survive decode while the phantom high bits are stripped.
let mixed = Data([0x00, 0xFF]) // bit 8 (board) known, bits 9..15 phantom
// it must survive decode while the phantom high bits (10+) are
// stripped. Bit 9 (prekeyBundle) is cleared to isolate the board bit.
let mixed = Data([0x00, 0xFD]) // bit 8 (board) known, bits 10..15 phantom
let decoded = SyncTypeFlags.decode(mixed)
#expect(decoded?.contains(.board) == true)
#expect(decoded?.rawValue == 0b1_0000_0000)
}
@Test func phantomBitsAreStrippedButKnownBitsSurvive() {
// Low byte = announce(0) + message(1); high byte bits 9+ are phantom.
let mixed = Data([0b0000_0011, 0xFE])
// Low byte = announce(0) + message(1); high byte bits 10+ are phantom.
let mixed = Data([0b0000_0011, 0xFC])
let decoded = SyncTypeFlags.decode(mixed)
#expect(decoded?.contains(.announce) == true)
#expect(decoded?.contains(.message) == true)