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>
This commit is contained in:
jack
2026-07-06 21:27:41 +02:00
co-authored by Claude Fable 5
parent ee148a018b
commit b481a70458
9 changed files with 309 additions and 30 deletions
@@ -329,4 +329,71 @@ struct PrekeyEndToEndTests {
// 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))
}
}
+47 -3
View File
@@ -489,14 +489,23 @@ struct GossipSyncManagerTests {
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let senderPeer = PeerID(hexData: sender)
// 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: Data([0x01]),
payload: try #require(bundle.encode()),
signature: nil,
ttl: 1
)
@@ -519,6 +528,41 @@ struct GossipSyncManagerTests {
#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 {
+50 -1
View File
@@ -156,6 +156,15 @@ struct LocalPrekeyStoreTests {
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()
@@ -190,6 +199,42 @@ struct LocalPrekeyStoreTests {
#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)
@@ -217,7 +262,11 @@ struct LocalPrekeyStoreTests {
let second = LocalPrekeyStore(keychain: keychain, now: { clock.now })
let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys()
#expect(reloadedGeneratedAt == generatedAt)
// 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)