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
+94 -13
View File
@@ -66,6 +66,13 @@ final class BLEService: NSObject {
// Throttle for re-broadcasting our own (unchanged) bundle; guarded by
// collectionsQueue barriers.
private var lastPrekeyBundleSentAt: Date?
// Prekey bundles that arrived before their owner's verified announce bound
// a signing key. The receive queue is concurrent, so a bundle can race
// ahead of the announce it depends on; we retain the latest such bundle per
// owner (bounded) and re-attempt attribution when the announce lands.
// Guarded by collectionsQueue barriers.
private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:]
private static let pendingPrekeyBundleCap = 64
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
@@ -2637,11 +2644,17 @@ extension BLEService {
let senderStaticKey: Data
if let prekeyID = envelope.prekeyID {
// Envelope v2: sealed to one of our one-time prekeys. Opening
// consumes the prekey (48h redelivery grace); regenerate and
// re-gossip a fresh bundle when the batch runs low.
(typedPayload, senderStaticKey) = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
if noiseService.replenishPrekeysIfNeeded() {
sendPrekeyBundle(force: true)
// consumes the prekey (48h redelivery grace), which shrinks our
// published bundle under a strictly newer generatedAt. Re-gossip
// so peers replace their cached copy and stop assigning the
// consumed ID before the grace lapses; force the broadcast when
// the batch also topped back up (low-water), otherwise let the
// rebroadcast throttle coalesce bursts.
let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
if opened.consumedPrekey {
let replenished = noiseService.replenishPrekeysIfNeeded()
sendPrekeyBundle(force: replenished)
}
} else {
(typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
@@ -2822,10 +2835,16 @@ extension BLEService {
gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
/// Ingests a gossiped prekey bundle: verify the inner Ed25519 signature
/// against the owner's announce-bound signing key, cache the latest copy
/// per owner, and only then let the packet enter our own gossip store so
/// we never help spread a bundle we couldn't attribute.
/// Ingests a gossiped prekey bundle. Attribution is layered: the outer
/// packet must originate from the bundle owner (fabricated sender IDs, used
/// to multiply cache/gossip entries, are rejected), and BOTH the inner
/// bundle signature and the outer packet signature must verify against the
/// owner's announce-bound signing key. Verifying the outer packet whose
/// signed bytes cover senderID and timestamp stops a valid bundle from
/// being replayed under a fresh timestamp or spoofed sender to pass
/// freshness or poison attribution. Only after that does the packet enter
/// our own gossip store, so we never help spread a bundle we couldn't
/// attribute.
private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) {
guard let bundle = PrekeyBundle.decode(packet.payload) else {
SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))", category: .security)
@@ -2833,17 +2852,73 @@ extension BLEService {
}
// Our own bundle is tracked at send time; a copy echoing back adds nothing.
guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return }
guard let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey),
noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey) else {
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(PeerID(publicKey: bundle.noiseStaticPublicKey).id.prefix(8))…)", category: .security)
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
// The owner's genuine bundle (direct or relayed) always carries the
// owner's senderID + outer signature; gossip resends preserve both. A
// packet whose senderID isn't the owner can't be authenticated here.
guard PeerID(hexData: packet.senderID) == owner else {
SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))", category: .security)
return
}
// Look up the announce-bound signing key and stash-if-unbound in ONE
// barrier: the receive queue is concurrent, so this bundle can race
// ahead of the announce that binds the key. Reading the live registry
// and stashing atomically closes the check-then-act gap against
// handleAnnounce's drain (see drainPendingPrekeyBundles).
let signingKey: Data? = collectionsQueue.sync(flags: .barrier) {
if let info = peerRegistry.info(for: owner),
info.noisePublicKey == bundle.noiseStaticPublicKey,
let key = info.signingPublicKey {
return key
}
// Offline-verified identities are stable across this race.
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner)
where candidate.publicKey == bundle.noiseStaticPublicKey {
if let key = candidate.signingPublicKey { return key }
}
// No binding yet: retain the latest bundle per owner, bounded, and
// retry once the verified announce lands.
if pendingPrekeyBundles[owner] != nil
|| pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap {
pendingPrekeyBundles[owner] = packet
}
return nil
}
guard let signingKey else {
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
return
}
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
}
/// Verify a bundle's inner + outer signatures against the owner's bound
/// signing key and, on success, cache it and let it enter our gossip store.
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
return
}
if prekeyBundleStore.ingest(bundle) {
SecureLogger.debug("🔑 Cached prekey bundle for \(PeerID(publicKey: bundle.noiseStaticPublicKey).id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
}
gossipSyncManager?.onPublicPacketSeen(packet)
}
/// Re-attempt any prekey bundle that arrived before this owner's announce
/// bound a signing key. Called from handleAnnounce after a verified
/// announce, in a barrier ordered after the registry write, so a bundle
/// stashed before the write is always observed here.
private func drainPendingPrekeyBundles(for owner: PeerID) {
let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) {
pendingPrekeyBundles.removeValue(forKey: owner)
}
guard let packet = pending,
let bundle = PrekeyBundle.decode(packet.payload),
let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return }
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
}
/// Ed25519 signing key bound to a Noise static key by a verified
/// announce: from the live registry when the owner is on the mesh, else
/// from identities persisted for offline verification.
@@ -3423,6 +3498,12 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
// A verified announce is the moment a signing key becomes bound to this
// owner's noise key: retry any prekey bundle that raced ahead of it.
if let result, result.isVerified {
drainPendingPrekeyBundles(for: result.peerID)
}
// Courier work: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them
// (or spray-able mail they could carry). Verified announces only.
@@ -457,9 +457,11 @@ final class NoiseEncryptionService {
/// Decrypt an envelope sealed to one of our one-time prekeys. On success
/// the prekey is marked consumed (its private key survives a 48h grace
/// window for spray-and-wait redeliveries, then is deleted for good).
/// Returns the payload and the sender's authenticated static key, same
/// contract as `openCourierPayload`.
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data) {
/// Returns the payload, the sender's authenticated static key (same
/// contract as `openCourierPayload`), and whether this open actually
/// retired the prekey false for a redelivery of already-consumed mail
/// so the caller can re-gossip the shrunken bundle only when it changed.
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
throw NoiseEncryptionError.unknownPrekey
}
@@ -474,8 +476,8 @@ final class NoiseEncryptionService {
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
localPrekeys.markConsumed(prekeyID)
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
let consumedPrekey = localPrekeys.markConsumed(prekeyID)
return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey)
}
/// Current signed prekey bundle for gossip, minting the initial batch on
@@ -109,13 +109,24 @@ final class LocalPrekeyStore {
/// Marks a prekey consumed (starts its grace clock). Idempotent: a
/// redelivery within the grace window does not restart the clock.
func markConsumed(_ id: UInt32) {
///
/// Returns true when this call actually retired a prekey, i.e. the
/// published bundle shrank. Consuming a prekey drops it from
/// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too:
/// otherwise peers that cached the old bundle reject the same-`generatedAt`
/// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed
/// ID, and their mail starts failing `unknownPrekey` once the 48h grace
/// lapses. The caller re-gossips on a true result.
@discardableResult
func markConsumed(_ id: UInt32) -> Bool {
queue.sync {
loadLocked()
guard let index = records.firstIndex(where: { $0.id == id }),
records[index].consumedAt == nil else { return }
records[index].consumedAt == nil else { return false }
records[index].consumedAt = now()
advanceGeneratedAtLocked()
persistLocked()
return true
}
}
@@ -174,7 +185,7 @@ final class LocalPrekeyStore {
records.append(Record(id: nextID, privateKey: key.rawRepresentation, createdAt: date, consumedAt: nil))
nextID &+= 1
}
generatedAt = UInt64(max(0, date.timeIntervalSince1970 * 1000))
advanceGeneratedAtLocked()
bundleChanged = true
SecureLogger.debug("🔑 Replenished one-time prekeys (unconsumed was \(unconsumed))", category: .security)
}
@@ -183,6 +194,15 @@ final class LocalPrekeyStore {
return bundleChanged
}
/// Advance `generatedAt` strictly monotonically. Uses wall-clock millis but
/// never repeats or regresses, so two changes within the same millisecond
/// still produce distinct, increasing stamps that peers' monotonic ingest
/// accepts.
private func advanceGeneratedAtLocked() {
let nowMillis = UInt64(max(0, now().timeIntervalSince1970 * 1000))
generatedAt = max(nowMillis, generatedAt &+ 1)
}
private func loadLocked() {
guard !loaded else { return }
loaded = true
+11 -5
View File
@@ -252,17 +252,23 @@ final class GossipSyncManager {
// spreads a bundle this node couldn't attribute.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let sender = PeerID(hexData: packet.senderID)
if let existing = latestPrekeyBundleByPeer[sender],
// Key by the bundle's authenticated identity (its noise static key),
// NOT the unauthenticated packet senderID. Otherwise one valid
// bundle re-broadcast under many fabricated sender IDs would create
// one cache entry each and exhaust the per-owner cap, starving
// legitimate bundles. One owner at most one entry.
guard let bundle = PrekeyBundle.decode(packet.payload) else { return }
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
if let existing = latestPrekeyBundleByPeer[owner],
existing.packet.timestamp >= packet.timestamp {
return
}
// Bounded peer count; replacing a known owner's bundle is always
// Bounded owner count; replacing a known owner's bundle is always
// allowed so the cap can't block refreshes.
guard latestPrekeyBundleByPeer[sender] != nil
guard latestPrekeyBundleByPeer[owner] != nil
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
latestPrekeyBundleByPeer[sender] = (id: idHex, packet: packet)
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
default:
break
}
@@ -290,6 +290,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func clearCurrentPublicTimeline() {
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
// The SPM test process shares the real Application Support tree, so this
// detached deletion can land mid-test under parallel scheduling and flake
// a file-dependent test. Tests never need the on-disk media cleared.
guard !TestEnvironment.isRunningTests else { return }
Task.detached(priority: .utility) {
do {
let base = try FileManager.default.url(
+5
View File
@@ -1278,6 +1278,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
// The SPM test process shares the real Application Support tree, so
// this detached tree-delete can land mid-test under parallel
// scheduling and flake a file-dependent test; tests never need the
// on-disk media wiped.
guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
@@ -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)