diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 1e76b0a3..d4bb2693 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -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. diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 5e244efb..9e7e6eae 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -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 diff --git a/bitchat/Services/Prekeys/LocalPrekeyStore.swift b/bitchat/Services/Prekeys/LocalPrekeyStore.swift index 83dfa4ae..26f9ec67 100644 --- a/bitchat/Services/Prekeys/LocalPrekeyStore.swift +++ b/bitchat/Services/Prekeys/LocalPrekeyStore.swift @@ -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 diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index e0cdd2ab..aad4b71b 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -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 } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index d6777a71..39c8aaae 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -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( diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index e4ab59d4..be78e5a3 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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) diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift index f7c61f3f..456932a7 100644 --- a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift @@ -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)) + } } diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 82c5c87d..f5866857 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -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 { diff --git a/bitchatTests/Prekeys/NoisePrekeyTests.swift b/bitchatTests/Prekeys/NoisePrekeyTests.swift index 7e58f04b..44a39d11 100644 --- a/bitchatTests/Prekeys/NoisePrekeyTests.swift +++ b/bitchatTests/Prekeys/NoisePrekeyTests.swift @@ -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)