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:
jack
2026-07-06 20:14:05 +02:00
co-authored by Claude Fable 5
parent 688b954fb8
commit ee148a018b
19 changed files with 1968 additions and 25 deletions
+53 -3
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)
@@ -363,6 +368,7 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
config.prekeyBundleSyncIntervalSeconds = 0
config.responseRateLimitMaxResponses = 1
config.responseRateLimitWindowSeconds = 60
@@ -417,6 +423,7 @@ struct GossipSyncManagerTests {
#expect(types.contains(.message))
#expect(types.contains(.fragment))
#expect(types.contains(.fileTransfer))
#expect(types.contains(.prekeyBundle))
}
@Test func handleRequestSyncHonorsTypeFilter() async throws {
@@ -469,6 +476,49 @@ 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
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let senderPeer = PeerID(hexData: sender)
let bundlePacket = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: sender,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data([0x01]),
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)
}
// MARK: - Archive persistence
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {