mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:25:20 +00:00
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>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user