Files
bitchat/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift
87910541ef 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>
2026-07-07 15:38:33 +02:00

198 lines
8.8 KiB
Swift

//
// PrekeyBundleStoreTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import CryptoKit
import BitFoundation
@testable import bitchat
/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest,
/// per-message prekey assignment (never reused across messages), expiry, and
/// the peer cap.
struct PrekeyBundleStoreTests {
private func makeBundle(
noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
ids: [UInt32] = [0, 1, 2],
generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
) -> PrekeyBundle {
PrekeyBundle(
noiseStaticPublicKey: noiseKey,
prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) },
generatedAt: generatedAt,
signature: Data(count: PrekeyBundle.signatureLength)
)
}
@Test func ingestKeepsLatestByGeneratedAt() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB0, count: 32)
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)
let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs)
#expect(store.ingest(new))
// Older (and equal) bundles never displace a newer one.
#expect(!store.ingest(old))
#expect(!store.ingest(new))
let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
#expect(assigned?.id == 2)
}
@Test func assignmentsConsumeDistinctPrekeysPerMessage() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB1, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11])))
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
#expect(first?.id == 10)
#expect(second?.id == 11)
// Exhausted: fall back to static sealing.
#expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil)
#expect(!store.hasUsableBundle(for: noiseKey))
}
@Test func redepositOfSameMessageReusesItsPrekey() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB2, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7])))
let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
#expect(first?.id == retry?.id)
#expect(first?.publicKey == retry?.publicKey)
// Only one prekey was burned.
let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
#expect(next?.id == 6)
}
@Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() {
let store = PrekeyBundleStore(persistsToDisk: false)
let noiseKey = Data(repeating: 0xB3, count: 32)
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)))
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0)
// The owner topped up: ID 1 survives (still unconsumed on their side),
// ID 0 rotated out, new IDs appear.
#expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs)))
// m1's assignment referenced a rotated-out ID; a re-deposit picks a
// fresh one rather than sealing to a dead key.
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
#expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8)
}
@Test func expiredBundleIsNeverUsed() {
var current = Date()
let store = PrekeyBundleStore(persistsToDisk: false, now: { current })
let noiseKey = Data(repeating: 0xB4, count: 32)
#expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
#expect(store.hasUsableBundle(for: noiseKey))
current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60)
#expect(!store.hasUsableBundle(for: noiseKey))
#expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil)
}
@Test func peerCapEvictsLeastRecentlyUpdated() {
var current = Date()
let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current })
let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) }
for key in keys {
#expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
current = current.addingTimeInterval(1)
}
// Oldest entry evicted; the two most recent survive.
#expect(!store.hasUsableBundle(for: keys[0]))
#expect(store.hasUsableBundle(for: keys[1]))
#expect(store.hasUsableBundle(for: keys[2]))
}
@Test func persistsAcrossInstancesAndWipes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true)
let fileURL = dir.appendingPathComponent("bundles.json")
defer { try? FileManager.default.removeItem(at: dir) }
let noiseKey = Data(repeating: 0xB5, count: 32)
let first = PrekeyBundleStore(fileURL: fileURL)
#expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2])))
#expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
// Consumption state survives a relaunch, so a restart can't reuse a prekey.
let second = PrekeyBundleStore(fileURL: fileURL)
#expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2)
second.wipe()
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
let third = PrekeyBundleStore(fileURL: fileURL)
#expect(!third.hasUsableBundle(for: noiseKey))
}
}
/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that
/// v1 decoders skip as unknown.
struct CourierEnvelopeV2Tests {
@Test func prekeyIDRoundTrips() throws {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength),
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
ciphertext: Data("ciphertext".utf8),
copies: 4,
prekeyID: 0xAABB_CCDD
)
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
#expect(decoded.prekeyID == 0xAABB_CCDD)
}
@Test func v1EnvelopeDecodesWithNilPrekeyID() throws {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength),
expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
ciphertext: Data("legacy".utf8)
)
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded.prekeyID == nil)
}
@Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws {
// Static-sealed envelopes must stay on the pre-prekey wire format.
let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength)
let expiry: UInt64 = 1_800_000_000_000
let ciphertext = Data("same".utf8)
let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode())
let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode())
#expect(v1 == v1Explicit)
// And a v2 envelope is the v1 bytes plus one trailing TLV a v1
// decoder skips as unknown.
let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode())
#expect(v2.prefix(v1.count) == v1)
#expect(v2.count == v1.count + 3 + 4)
}
@Test func withCopiesPreservesPrekeyID() {
let envelope = CourierEnvelope(
recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength),
expiry: 1,
ciphertext: Data([0x01]),
copies: 4,
prekeyID: 9
)
#expect(envelope.withCopies(2).prekeyID == 9)
}
}