Prekey bundles: forward-secret async first contact for courier mail (#1381)

* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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>

* 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>

* Update sync tests for prekeyBundle as bit 9 / default sync round

Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables
a prekey sync round by default. That broke tests authored by other PRs
that assumed bit 9 was phantom or that only their own sync round fires:

- SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+
  (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned.
- GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync
  round in configs that run maintenance (as they already do for message/
  fragment/fileTransfer), so they isolate the behavior under test.

Full app suite (1301 tests) green locally via SPM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 15:38:33 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent f9032cf2b9
commit 87910541ef
23 changed files with 2254 additions and 39 deletions
@@ -3,5 +3,5 @@ import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [.vouch]
static let localSupported: PeerCapabilities = [.vouch, .prekeys]
}
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle:
return false
}
}
+238 -8
View File
@@ -62,6 +62,19 @@ final class BLEService: NSObject {
// Local-only store-and-forward counters; nil in unit tests.
var sfMetrics: StoreAndForwardMetrics?
// Verified one-time prekey bundles gossiped by other peers, used to seal
// courier mail forward-secretly. Injectable for tests.
var prekeyBundleStore: PrekeyBundleStore = .shared
// 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
// Gateway mode: sink for received nostrCarrier packets (set by app
// wiring, called on the main actor after transport-level checks) and the
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
@@ -320,7 +333,10 @@ final class BLEService: NSObject {
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
)
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
@@ -371,6 +387,8 @@ final class BLEService: NSObject {
ingressLinks.removeAll()
recentTrafficTracker.removeAll()
scheduledRelays.cancelAll()
// Let the post-panic identity publish its fresh bundle promptly.
lastPrekeyBundleSentAt = nil
return transfers
}
@@ -1368,6 +1386,10 @@ final class BLEService: NSObject {
}
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket)
// Keep our prekey bundle riding alongside presence (throttled; the
// send is a no-op when the bundle was refreshed recently).
sendPrekeyBundle()
}
// MARK: QR Verification over Noise
@@ -1772,6 +1794,10 @@ extension BLEService {
handleReceivedPacket(packet, from: fromPeerID)
}
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
}
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
let claimedSenderID = PeerID(hexData: packet.senderID)
guard case .success = BLEIngressLinkRegistry.packetContext(
@@ -2751,10 +2777,13 @@ extension BLEService {
// MARK: Courier Store-and-Forward
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
/// the envelope to the given couriers for physical delivery. Returns false
/// when no courier is connected, the payload cannot be built, or sealing
/// fails; link writes are queued asynchronously after the envelope is ready.
/// Seal `content` for the recipient and hand the envelope to the given
/// couriers for physical delivery. When a verified one-time prekey bundle
/// is cached for the recipient, sealing targets one of its prekeys
/// (forward secret, envelope v2); otherwise it falls back to their static
/// key (one-way Noise X, v1) exactly as before. Returns false when no
/// courier is connected, the payload cannot be built, or sealing fails;
/// link writes are queued asynchronously after the envelope is ready.
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
let connected = couriers.filter { isPeerConnected($0) }
guard !connected.isEmpty,
@@ -2765,7 +2794,15 @@ extension BLEService {
let payload: Data
do {
let now = Date()
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
let sealed: Data
let prekeyID: UInt32?
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
prekeyID = prekey.id
} else {
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
prekeyID = nil
}
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
@@ -2773,7 +2810,8 @@ extension BLEService {
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed,
copies: TransportConfig.courierInitialCopies
copies: TransportConfig.courierInitialCopies,
prekeyID: prekeyID
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
@@ -2792,6 +2830,22 @@ extension BLEService {
return true
}
/// The prekey to seal a courier message with, or nil to fall back to
/// static sealing. The real signal is a verified, unexpired bundle with a
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
/// for peers we currently see on the mesh (a cached bundle can outlive a
/// peer's downgrade to a build that no longer holds the privates).
/// Re-deposits of the same message reuse its assigned prekey, so one
/// message consumes exactly one prekey ID regardless of courier count.
private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
let shortID = PeerID(publicKey: recipientNoiseKey)
let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) {
return nil
}
return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey)
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
@@ -2827,7 +2881,25 @@ extension BLEService {
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
do {
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
let typedPayload: Data
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), 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)
}
guard let typeRaw = typedPayload.first,
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
@@ -2958,6 +3030,155 @@ extension BLEService {
}
}
// MARK: One-Time Prekey Bundles
/// Broadcasts our signed prekey bundle and tracks it for gossip sync.
/// Unforced sends (piggybacked on announces) are throttled gossip does
/// the spreading, the broadcast just keeps our own gossip entry fresh.
/// Forced sends (bundle changed after consumption) go immediately.
private func sendPrekeyBundle(force: Bool = false) {
let now = Date()
let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) {
if !force,
let last = lastPrekeyBundleSentAt,
now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds {
return false
}
lastPrekeyBundleSentAt = now
return true
}
guard shouldSend else { return }
guard let bundle = noiseService.currentPrekeyBundle(),
let payload = bundle.encode() else {
SecureLogger.error("❌ Failed to build prekey bundle", category: .security)
return
}
let packet = BitchatPacket(
type: MessageType.prekeyBundle.rawValue,
senderID: myPeerIDData,
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
guard let signedPacket = noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security)
return
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(signedPacket)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(signedPacket)
}
}
gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
/// 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)
return
}
// Our own bundle is tracked at send time; a copy echoing back adds nothing.
guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return }
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 \(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.
private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? {
let shortID = PeerID(publicKey: noiseKey)
if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }),
info.noisePublicKey == noiseKey,
let signingKey = info.signingPublicKey {
return signingKey
}
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID)
where candidate.publicKey == noiseKey {
if let signingKey = candidate.signingPublicKey {
return signingKey
}
}
return nil
}
// MARK: Gateway carrier (nostrCarrier)
/// Sign and send an encoded `toGateway` carrier payload directed at a
@@ -3537,6 +3758,9 @@ extension BLEService {
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .prekeyBundle:
handlePrekeyBundle(packet, from: senderID)
case .boardPost:
// Invalid or deleted posts must not spread; skip the relay step.
guard handleBoardPost(packet, from: senderID) else { return }
@@ -3615,6 +3839,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.
+9 -3
View File
@@ -42,9 +42,11 @@ final class CourierStore {
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
let prekeyID: UInt32?
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
}
init(
@@ -56,7 +58,8 @@ final class CourierStore {
tier: CourierDepositTier,
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil
lastRemoteHandoverAt: Date? = nil,
prekeyID: UInt32? = nil
) {
self.recipientTag = recipientTag
self.expiry = expiry
@@ -67,6 +70,7 @@ final class CourierStore {
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
self.prekeyID = prekeyID
}
// Files written before tiers/spray lack the newer fields; treat that
@@ -82,6 +86,7 @@ final class CourierStore {
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
}
}
@@ -186,7 +191,8 @@ final class CourierStore {
depositorNoiseKey: depositorNoiseKey,
storedAt: date,
tier: tier,
copies: envelope.copies
copies: envelope.copies,
prekeyID: envelope.prekeyID
))
persistLocked()
return true
+107 -1
View File
@@ -172,6 +172,10 @@ final class NoiseEncryptionService {
// Security components
private let rateLimiter = NoiseRateLimiter()
private let keychain: KeychainManagerProtocol
// One-time prekeys for forward-secret courier sealing (lazy generation
// inside the store; the batch is minted on first bundle build).
private let localPrekeys: LocalPrekeyStore
// Session maintenance
private var rekeyTimer: Timer?
@@ -200,6 +204,7 @@ final class NoiseEncryptionService {
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
// BCH-01-009: Load or create static identity key with proper error handling
let loadedKey: Curve25519.KeyAgreement.PrivateKey
@@ -412,13 +417,111 @@ final class NoiseEncryptionService {
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
// MARK: - One-Time Prekey Envelopes (forward-secret Noise X)
/// Domain separation for prekey-sealed envelopes: distinct from both the
/// interactive XX transcripts and static-sealed courier envelopes, and
/// bound to the specific prekey ID so a ciphertext cannot be replayed
/// against a different prekey.
private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8)
private static func prekeyPrologue(for prekeyID: UInt32) -> Data {
var prologue = prekeyProloguePrefix
var big = prekeyID.bigEndian
withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) }
return prologue
}
/// Encrypt a payload to one of the recipient's gossiped one-time prekeys
/// (Noise X where the responder static is the prekey, not the identity
/// key). Unlike `sealCourierPayload`, this is forward secret: once the
/// recipient consumes the prekey and its grace window lapses, the private
/// key is deleted and captured ciphertext becomes undecryptable even if
/// the recipient's identity key is later compromised. The initiator's
/// static still rides inside (encrypted), so the recipient authenticates
/// the sender exactly as with static-sealed envelopes.
func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.prekeyPrologue(for: recipientPrekey.id)
)
return try handshake.writeMessage(payload: payload)
}
/// 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, 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
}
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: prekeyPrivate,
prologue: Self.prekeyPrologue(for: prekeyID)
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
let consumedPrekey = localPrekeys.markConsumed(prekeyID)
return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey)
}
/// Current signed prekey bundle for gossip, minting the initial batch on
/// first use. Nil only when signing fails.
func currentPrekeyBundle() -> PrekeyBundle? {
let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys()
guard !prekeys.isEmpty else { return nil }
let unsigned = PrekeyBundle(
noiseStaticPublicKey: getStaticPublicKeyData(),
prekeys: prekeys,
generatedAt: generatedAt,
signature: Data(count: PrekeyBundle.signatureLength)
)
guard let signature = signData(unsigned.signableBytes()) else { return nil }
return PrekeyBundle(
noiseStaticPublicKey: unsigned.noiseStaticPublicKey,
prekeys: prekeys,
generatedAt: generatedAt,
signature: signature
)
}
/// Verify a peer's bundle signature against their announce-bound Ed25519
/// signing key.
func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool {
verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey)
}
/// Prune dead prekeys and top the batch back up when consumption runs it
/// low. Returns true when the published bundle changed and should be
/// re-gossiped.
@discardableResult
func replenishPrekeysIfNeeded() -> Bool {
localPrekeys.replenishIfNeeded()
}
/// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
// Clear from keychain
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
// One-time prekey privates go with the identity they were bound to.
localPrekeys.wipe()
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
// Stop rekey timer
stopRekeyTimer()
@@ -812,4 +915,7 @@ struct NoiseMessage: Codable {
enum NoiseEncryptionError: Error {
case handshakeRequired
case sessionNotEstablished
/// Envelope references a prekey ID we don't hold (never ours, already
/// deleted after its grace window, or wiped in a panic).
case unknownPrekey
}
@@ -0,0 +1,228 @@
//
// LocalPrekeyStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
/// Owns this device's one-time Curve25519 prekey private keys.
///
/// Privates persist in the Keychain (single blob, same protection class as
/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the
/// gossiped bundle; when consumption drops the unconsumed count below
/// `replenishThreshold`, the batch tops back up and the bundle's
/// `generatedAt` bumps so peers replace their cached copy.
///
/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext
/// (or a re-seal of the same message to the same prekey ID) can arrive via
/// several couriers days apart. A consumed prekey's private key is therefore
/// retained for `consumedGraceSeconds` after first use and only then deleted.
/// Tradeoff: during the grace window a compromise of the device still exposes
/// mail sealed to that prekey the forward-secrecy clock starts at deletion,
/// not at first open. Refusing new ciphertexts while accepting redeliveries
/// is not possible (the recipient cannot distinguish them), so the window is
/// kept short and fixed.
final class LocalPrekeyStore {
struct Record: Codable {
let id: UInt32
let privateKey: Data
let createdAt: Date
var consumedAt: Date?
}
private struct Persisted: Codable {
var records: [Record]
var nextID: UInt32
var generatedAt: UInt64
}
enum Policy {
static let batchSize = PrekeyBundle.maxPrekeys
static let replenishThreshold = 3
/// How long a consumed prekey private survives for duplicate courier
/// deliveries of mail sealed to it.
static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60
/// Unconsumed prekeys older than this are rotated out: no honest
/// sender seals to a bundle that stale (see
/// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`).
static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60
}
private static let keychainKey = "prekeysV1"
private let keychain: KeychainManagerProtocol
private let now: () -> Date
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local")
// Guarded by `queue`.
private var records: [Record] = []
private var nextID: UInt32 = 0
private var generatedAt: UInt64 = 0
private var loaded = false
init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) {
self.keychain = keychain
self.now = now
}
// MARK: - Bundle contents (public prekeys)
/// Unconsumed public prekeys for the gossiped bundle, generating the
/// initial batch on first use. Sorted by ID for canonical signing bytes.
func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) {
queue.sync {
loadLocked()
_ = replenishLocked()
let prekeys = records
.filter { $0.consumedAt == nil }
.sorted { $0.id < $1.id }
.compactMap { record -> PrekeyBundle.Prekey? in
guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil }
return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation)
}
return (prekeys, generatedAt)
}
}
// MARK: - Opening (private prekeys)
/// Private key for a prekey ID: unconsumed, or consumed within the
/// redelivery grace window.
func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? {
queue.sync {
loadLocked()
let date = now()
guard let record = records.first(where: { $0.id == id }) else { return nil }
if let consumedAt = record.consumedAt,
date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds {
return nil
}
return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey)
}
}
/// Marks a prekey consumed (starts its grace clock). Idempotent: a
/// redelivery within the grace window does not restart the clock.
///
/// 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 false }
records[index].consumedAt = now()
advanceGeneratedAtLocked()
persistLocked()
return true
}
}
/// Prunes dead prekeys and tops the unconsumed batch back up when it runs
/// low. Returns true when the published bundle changed (caller should
/// re-gossip).
@discardableResult
func replenishIfNeeded() -> Bool {
queue.sync {
loadLocked()
return replenishLocked()
}
}
var unconsumedCount: Int {
queue.sync {
loadLocked()
return records.filter { $0.consumedAt == nil }.count
}
}
/// Panic wipe: drop all prekey privates from memory and the Keychain.
func wipe() {
queue.sync {
records.removeAll()
nextID = 0
generatedAt = 0
loaded = true
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey)
}
}
// MARK: - Internals (call only on `queue`)
private func replenishLocked() -> Bool {
let date = now()
// Consumed prekeys past the grace window are gone for good; stale
// unconsumed ones rotate out (their bundle is too old to seal to).
let recordsBefore = records.count
let unconsumedBefore = records.filter { $0.consumedAt == nil }.count
records.removeAll { record in
if let consumedAt = record.consumedAt {
return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds
}
return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds
}
// Only a change to the *unconsumed* set alters the published bundle;
// grace-expired consumed keys were never in it.
let unconsumed = records.filter { $0.consumedAt == nil }.count
var bundleChanged = unconsumed != unconsumedBefore
if unconsumed < Policy.replenishThreshold {
for _ in unconsumed..<Policy.batchSize {
let key = Curve25519.KeyAgreement.PrivateKey()
records.append(Record(id: nextID, privateKey: key.rawRepresentation, createdAt: date, consumedAt: nil))
nextID &+= 1
}
advanceGeneratedAtLocked()
bundleChanged = true
SecureLogger.debug("🔑 Replenished one-time prekeys (unconsumed was \(unconsumed))", category: .security)
}
if bundleChanged || records.count != recordsBefore { persistLocked() }
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
guard let data = keychain.getIdentityKey(forKey: Self.keychainKey),
let persisted = try? JSONDecoder().decode(Persisted.self, from: data) else {
return
}
records = persisted.records
nextID = persisted.nextID
generatedAt = persisted.generatedAt
}
private func persistLocked() {
let persisted = Persisted(records: records, nextID: nextID, generatedAt: generatedAt)
guard let data = try? JSONEncoder().encode(persisted) else {
SecureLogger.error("Failed to encode prekey store", category: .keychain)
return
}
if !keychain.saveIdentityKey(data, forKey: Self.keychainKey) {
SecureLogger.error("Failed to persist prekey store", category: .keychain)
}
}
}
@@ -0,0 +1,210 @@
//
// PrekeyBundleStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Foundation
/// Signature-verified one-time prekey bundles received from other peers.
///
/// One bundle per Noise static key: a newer `generatedAt` replaces the cached
/// copy, keeping the IDs we already sealed with marked used so a prekey is
/// never reused across messages. Assignments are remembered per message ID so
/// deposit retries of the same message re-use its prekey (and its budget)
/// instead of burning a fresh one per courier.
///
/// Only public key material lives here; it persists to disk so a sender can
/// prekey-seal for recipients met long ago. Included in the panic wipe.
final class PrekeyBundleStore {
struct StoredBundle: Codable {
let noiseKey: Data
var generatedAt: UInt64
var prekeyIDs: [UInt32]
var prekeyPublicKeys: [Data]
/// IDs this device already sealed with (never reused).
var usedIDs: Set<UInt32>
/// messageID prekey ID, so re-deposits of one message share one prekey.
var assignments: [String: UInt32]
var updatedAt: Date
}
enum Limits {
static let maxPeers = 200
/// Don't seal to bundles older than this: the owner may have rotated
/// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
}
static let shared = PrekeyBundleStore()
private var bundles: [Data: StoredBundle] = [:]
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
private let fileURL: URL?
private let maxPeers: Int
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(
persistsToDisk: Bool = true,
fileURL: URL? = nil,
maxPeers: Int = Limits.maxPeers,
now: @escaping () -> Date = Date.init
) {
self.now = now
self.maxPeers = maxPeers
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Ingest
/// Stores a bundle whose signature the caller has already verified
/// against the owner's announce-bound signing key. Returns false when an
/// equal-or-newer bundle is already cached (nothing changed).
@discardableResult
func ingest(_ bundle: PrekeyBundle) -> Bool {
guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
!bundle.prekeys.isEmpty else { return false }
return queue.sync {
if let existing = bundles[bundle.noiseStaticPublicKey],
existing.generatedAt >= bundle.generatedAt {
return false
}
let previous = bundles[bundle.noiseStaticPublicKey]
let newIDs = Set(bundle.prekeys.map(\.id))
// Keep consumption state for IDs the fresh bundle still offers
// (a top-up keeps the owner's unconsumed keys); drop the rest.
let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
bundles[bundle.noiseStaticPublicKey] = StoredBundle(
noiseKey: bundle.noiseStaticPublicKey,
generatedAt: bundle.generatedAt,
prekeyIDs: bundle.prekeys.map(\.id),
prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
usedIDs: carriedUsed,
assignments: carriedAssignments,
updatedAt: now()
)
enforceCapLocked()
persistLocked()
return true
}
}
// MARK: - Sealing support
/// Whether an unexpired bundle with sealable prekeys is cached for a peer.
func hasUsableBundle(for noiseKey: Data) -> Bool {
queue.sync {
guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
return bundle.usedIDs.count < bundle.prekeyIDs.count
}
}
/// The prekey to seal a message with: the message's existing assignment if
/// any (re-deposits reuse it), else the lowest unused ID, which is then
/// marked used. Nil when no fresh bundle is cached or all its prekeys are
/// spent callers fall back to static sealing.
func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
queue.sync {
guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
if let assigned = bundle.assignments[messageID],
let index = bundle.prekeyIDs.firstIndex(of: assigned) {
return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
}
guard let index = bundle.prekeyIDs.indices
.filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
.min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
return nil
}
let id = bundle.prekeyIDs[index]
bundle.usedIDs.insert(id)
bundle.assignments[messageID] = id
bundle.updatedAt = now()
bundles[recipientNoiseKey] = bundle
persistLocked()
return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
}
}
// MARK: - Maintenance
/// Panic wipe: drop all cached bundles from memory and disk.
func wipe() {
queue.sync {
bundles.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
}
// MARK: - Internals (call only on `queue`)
private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
}
private func enforceCapLocked() {
while bundles.count > maxPeers {
guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
bundles.removeValue(forKey: victim.key)
}
}
private func persistLocked() {
guard let fileURL else { return }
do {
if bundles.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(Array(bundles.values))
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
return
}
for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
bundles[bundle.noiseKey] = bundle
}
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("prekeys", isDirectory: true)
.appendingPathComponent("bundles.json")
}
}
+12
View File
@@ -313,4 +313,16 @@ enum TransportConfig {
// Cooldown between speculative multi-hop handovers of the same envelope
// toward a recipient heard only via relayed announces.
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
// One-time prekey bundles (forward-secret courier sealing)
// Own gossip-sync round for bundles: modest cadence, bounded peer count,
// and a long freshness window so bundles persist mesh-wide while their
// owners are away.
static let syncPrekeyBundleCapacity: Int = 200
static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0
static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
// Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on
// announces, keep it alive in peers' gossip stores; changed bundles are
// sent immediately.
static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60
}
+82 -3
View File
@@ -83,6 +83,11 @@ final class GossipSyncManager {
var boardSyncIntervalSeconds: TimeInterval = 60.0
var responseRateLimitMaxResponses: Int = 8
var responseRateLimitWindowSeconds: TimeInterval = 30.0
// Prekey bundles: one per peer, own sync round, long freshness so
// bundles persist mesh-wide while their owners are offline.
var prekeyBundleCapacity: Int = 200
var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0
var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
}
private let myPeerID: PeerID
@@ -102,6 +107,10 @@ final class GossipSyncManager {
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
// Latest verified prekey bundle per owner. Unlike announces, bundles are
// NOT dropped on leave/stale peer: their whole purpose is reaching a
// sender while the owner is away.
private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
private var archiveDirty = false
// Timer
@@ -130,6 +139,9 @@ final class GossipSyncManager {
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
}
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
}
@@ -169,6 +181,9 @@ final class GossipSyncManager {
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
types.formUnion(.fileTransfer)
}
if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
types.formUnion(.prekeyBundle)
}
if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
types.formUnion(.board)
}
@@ -186,9 +201,15 @@ final class GossipSyncManager {
// messages get the long town-crier window; fragments, file transfers and
// announces keep the short one.
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
let maxAgeSeconds = packet.type == MessageType.message.rawValue
? config.publicMessageMaxAgeSeconds
: config.maxMessageAgeSeconds
let maxAgeSeconds: TimeInterval
switch packet.type {
case MessageType.message.rawValue:
maxAgeSeconds = config.publicMessageMaxAgeSeconds
case MessageType.prekeyBundle.rawValue:
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
default:
maxAgeSeconds = config.maxMessageAgeSeconds
}
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
@@ -241,6 +262,29 @@ final class GossipSyncManager {
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
case .prekeyBundle:
// Callers only feed verified bundles here (own bundles at send
// time, peers' after signature verification), so gossip never
// spreads a bundle this node couldn't attribute.
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
// 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 owner count; replacing a known owner's bundle is always
// allowed so the cap can't block refreshes.
guard latestPrekeyBundleByPeer[owner] != nil
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
default:
break
}
@@ -410,6 +454,23 @@ final class GossipSyncManager {
}
}
// Like announces, prekey bundles are exempt from the since-cursor:
// there is at most one per owner (newer replaces older), so the
// resend cost is bounded and a joining peer must be able to learn
// bundles generated long before it arrived.
if requestedTypes.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.boardPost) {
// The board store already filters to live posts and tombstones;
// no freshness window applies (posts sync until their own expiry).
@@ -444,6 +505,11 @@ final class GossipSyncManager {
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
}
if types.contains(.prekeyBundle) {
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
if types.contains(.boardPost) {
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
}
@@ -463,6 +529,8 @@ final class GossipSyncManager {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
} else if types == .prekeyBundle {
cap = max(1, config.prekeyBundleCapacity)
} else if types == .board {
cap = max(1, config.boardCapacity)
} else {
@@ -506,6 +574,9 @@ final class GossipSyncManager {
}
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
isPacketFresh(pair.packet)
}
}
// MARK: - Archive (public message persistence)
@@ -596,6 +667,8 @@ final class GossipSyncManager {
}
private func removeState(for peerID: PeerID) {
// Deliberately keeps the peer's prekey bundle: bundles exist to reach
// owners who left the mesh, and they age out on their own schedule.
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
let messageCountBefore = messages.packets.count
messages.remove { PeerID(hexData: $0.senderID) == peerID }
@@ -621,6 +694,12 @@ extension GossipSyncManager {
}
}
func _hasPrekeyBundle(for peerID: PeerID) -> Bool {
queue.sync {
latestPrekeyBundleByPeer[peerID] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
+7
View File
@@ -47,6 +47,11 @@ struct SyncTypeFlags: OptionSet {
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
// would waste airtime and extend their lifetime.
case .nostrCarrier: return nil
// Prekey bundles gossip like board posts. The bitfield is a
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
// clients decode the wider flags and simply never match the new bits.
case .prekeyBundle: return 9
}
}
@@ -64,6 +69,7 @@ struct SyncTypeFlags: OptionSet {
// type-aware sync (#853) accept 1-8 bytes and map unknown bits to no
// known type, so old clients ignore board rounds instead of choking.
case 8: return .boardPost
case 9: return .prekeyBundle
default:
return nil
}
@@ -74,6 +80,7 @@ struct SyncTypeFlags: OptionSet {
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
static let board = SyncTypeFlags(messageTypes: [.boardPost])
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
@@ -292,6 +292,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) {
// Skipped under tests: the test process shares the user's real
// ~/Library/Application Support/files tree, and this detached
+4
View File
@@ -1209,6 +1209,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
GossipMessageArchive.wipeDefault()
StoreAndForwardMetrics.shared.reset()
// Drop cached peers' prekey bundles (who we could write to is
// metadata too). Our own prekey privates are keychain-backed and go
// with deleteAllKeychainData above plus the identity reset below.
PrekeyBundleStore.shared.wipe()
// Drop bulletin-board posts and tombstones (memory and disk); board
// posts are signed with our identity key and persist for days.
BoardStore.shared.wipe()