mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
f9032cf2b9
commit
87910541ef
@@ -3,5 +3,5 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// 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) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ final class BLEService: NSObject {
|
|||||||
// Local-only store-and-forward counters; nil in unit tests.
|
// Local-only store-and-forward counters; nil in unit tests.
|
||||||
var sfMetrics: StoreAndForwardMetrics?
|
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
|
// Gateway mode: sink for received nostrCarrier packets (set by app
|
||||||
// wiring, called on the main actor after transport-level checks) and the
|
// wiring, called on the main actor after transport-level checks) and the
|
||||||
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
|
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
|
||||||
@@ -320,7 +333,10 @@ final class BLEService: NSObject {
|
|||||||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
||||||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
|
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
|
||||||
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
|
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.
|
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
|
||||||
@@ -371,6 +387,8 @@ final class BLEService: NSObject {
|
|||||||
ingressLinks.removeAll()
|
ingressLinks.removeAll()
|
||||||
recentTrafficTracker.removeAll()
|
recentTrafficTracker.removeAll()
|
||||||
scheduledRelays.cancelAll()
|
scheduledRelays.cancelAll()
|
||||||
|
// Let the post-panic identity publish its fresh bundle promptly.
|
||||||
|
lastPrekeyBundleSentAt = nil
|
||||||
return transfers
|
return transfers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1368,6 +1386,10 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
// Ensure our own announce is included in sync state
|
// Ensure our own announce is included in sync state
|
||||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
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
|
// MARK: QR Verification over Noise
|
||||||
@@ -1772,6 +1794,10 @@ extension BLEService {
|
|||||||
handleReceivedPacket(packet, from: fromPeerID)
|
handleReceivedPacket(packet, from: fromPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
|
||||||
|
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
|
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
|
||||||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||||||
guard case .success = BLEIngressLinkRegistry.packetContext(
|
guard case .success = BLEIngressLinkRegistry.packetContext(
|
||||||
@@ -2751,10 +2777,13 @@ extension BLEService {
|
|||||||
|
|
||||||
// MARK: Courier Store-and-Forward
|
// MARK: Courier Store-and-Forward
|
||||||
|
|
||||||
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
|
/// Seal `content` for the recipient and hand the envelope to the given
|
||||||
/// the envelope to the given couriers for physical delivery. Returns false
|
/// couriers for physical delivery. When a verified one-time prekey bundle
|
||||||
/// when no courier is connected, the payload cannot be built, or sealing
|
/// is cached for the recipient, sealing targets one of its prekeys
|
||||||
/// fails; link writes are queued asynchronously after the envelope is ready.
|
/// (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 {
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||||
let connected = couriers.filter { isPeerConnected($0) }
|
let connected = couriers.filter { isPeerConnected($0) }
|
||||||
guard !connected.isEmpty,
|
guard !connected.isEmpty,
|
||||||
@@ -2765,7 +2794,15 @@ extension BLEService {
|
|||||||
let payload: Data
|
let payload: Data
|
||||||
do {
|
do {
|
||||||
let now = Date()
|
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(
|
let envelope = CourierEnvelope(
|
||||||
recipientTag: CourierEnvelope.recipientTag(
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
noiseStaticKey: recipientNoiseKey,
|
noiseStaticKey: recipientNoiseKey,
|
||||||
@@ -2773,7 +2810,8 @@ extension BLEService {
|
|||||||
),
|
),
|
||||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||||
ciphertext: sealed,
|
ciphertext: sealed,
|
||||||
copies: TransportConfig.courierInitialCopies
|
copies: TransportConfig.courierInitialCopies,
|
||||||
|
prekeyID: prekeyID
|
||||||
)
|
)
|
||||||
guard let encoded = envelope.encode() else { return false }
|
guard let encoded = envelope.encode() else { return false }
|
||||||
payload = encoded
|
payload = encoded
|
||||||
@@ -2792,6 +2830,22 @@ extension BLEService {
|
|||||||
return true
|
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 {
|
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.courierEnvelope.rawValue,
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
@@ -2827,7 +2881,25 @@ extension BLEService {
|
|||||||
|
|
||||||
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||||
do {
|
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,
|
guard let typeRaw = typedPayload.first,
|
||||||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||||
payloadType == .privateMessage else {
|
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)
|
// MARK: Gateway carrier (nostrCarrier)
|
||||||
|
|
||||||
/// Sign and send an encoded `toGateway` carrier payload directed at a
|
/// Sign and send an encoded `toGateway` carrier payload directed at a
|
||||||
@@ -3537,6 +3758,9 @@ extension BLEService {
|
|||||||
case .courierEnvelope:
|
case .courierEnvelope:
|
||||||
handleCourierEnvelope(packet, from: peerID)
|
handleCourierEnvelope(packet, from: peerID)
|
||||||
|
|
||||||
|
case .prekeyBundle:
|
||||||
|
handlePrekeyBundle(packet, from: senderID)
|
||||||
|
|
||||||
case .boardPost:
|
case .boardPost:
|
||||||
// Invalid or deleted posts must not spread; skip the relay step.
|
// Invalid or deleted posts must not spread; skip the relay step.
|
||||||
guard handleBoardPost(packet, from: senderID) else { return }
|
guard handleBoardPost(packet, from: senderID) else { return }
|
||||||
@@ -3615,6 +3839,12 @@ extension BLEService {
|
|||||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let result = announceHandler.handle(packet, from: 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
|
// 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
|
// static key, so check whether we're carrying mail addressed to them
|
||||||
// (or spray-able mail they could carry). Verified announces only.
|
// (or spray-able mail they could carry). Verified announces only.
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ final class CourierStore {
|
|||||||
var sprayedTo: Set<Data>
|
var sprayedTo: Set<Data>
|
||||||
/// Last speculative multi-hop handover toward a relayed announce.
|
/// Last speculative multi-hop handover toward a relayed announce.
|
||||||
var lastRemoteHandoverAt: Date?
|
var lastRemoteHandoverAt: Date?
|
||||||
|
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
||||||
|
let prekeyID: UInt32?
|
||||||
|
|
||||||
var envelope: CourierEnvelope {
|
var envelope: CourierEnvelope {
|
||||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -56,7 +58,8 @@ final class CourierStore {
|
|||||||
tier: CourierDepositTier,
|
tier: CourierDepositTier,
|
||||||
copies: UInt8,
|
copies: UInt8,
|
||||||
sprayedTo: Set<Data> = [],
|
sprayedTo: Set<Data> = [],
|
||||||
lastRemoteHandoverAt: Date? = nil
|
lastRemoteHandoverAt: Date? = nil,
|
||||||
|
prekeyID: UInt32? = nil
|
||||||
) {
|
) {
|
||||||
self.recipientTag = recipientTag
|
self.recipientTag = recipientTag
|
||||||
self.expiry = expiry
|
self.expiry = expiry
|
||||||
@@ -67,6 +70,7 @@ final class CourierStore {
|
|||||||
self.copies = copies
|
self.copies = copies
|
||||||
self.sprayedTo = sprayedTo
|
self.sprayedTo = sprayedTo
|
||||||
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||||
|
self.prekeyID = prekeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files written before tiers/spray lack the newer fields; treat that
|
// 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
|
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||||
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||||
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
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,
|
depositorNoiseKey: depositorNoiseKey,
|
||||||
storedAt: date,
|
storedAt: date,
|
||||||
tier: tier,
|
tier: tier,
|
||||||
copies: envelope.copies
|
copies: envelope.copies,
|
||||||
|
prekeyID: envelope.prekeyID
|
||||||
))
|
))
|
||||||
persistLocked()
|
persistLocked()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ final class NoiseEncryptionService {
|
|||||||
// Security components
|
// Security components
|
||||||
private let rateLimiter = NoiseRateLimiter()
|
private let rateLimiter = NoiseRateLimiter()
|
||||||
private let keychain: KeychainManagerProtocol
|
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
|
// Session maintenance
|
||||||
private var rekeyTimer: Timer?
|
private var rekeyTimer: Timer?
|
||||||
@@ -200,6 +204,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||||
|
|
||||||
// BCH-01-009: Load or create static identity key with proper error handling
|
// BCH-01-009: Load or create static identity key with proper error handling
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
@@ -412,13 +417,111 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
|
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)
|
/// Clear persistent identity (for panic mode)
|
||||||
func clearPersistentIdentity() {
|
func clearPersistentIdentity() {
|
||||||
// Clear from keychain
|
// Clear from keychain
|
||||||
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||||
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
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)
|
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
||||||
// Stop rekey timer
|
// Stop rekey timer
|
||||||
stopRekeyTimer()
|
stopRekeyTimer()
|
||||||
@@ -812,4 +915,7 @@ struct NoiseMessage: Codable {
|
|||||||
enum NoiseEncryptionError: Error {
|
enum NoiseEncryptionError: Error {
|
||||||
case handshakeRequired
|
case handshakeRequired
|
||||||
case sessionNotEstablished
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -313,4 +313,16 @@ enum TransportConfig {
|
|||||||
// Cooldown between speculative multi-hop handovers of the same envelope
|
// Cooldown between speculative multi-hop handovers of the same envelope
|
||||||
// toward a recipient heard only via relayed announces.
|
// toward a recipient heard only via relayed announces.
|
||||||
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ final class GossipSyncManager {
|
|||||||
var boardSyncIntervalSeconds: TimeInterval = 60.0
|
var boardSyncIntervalSeconds: TimeInterval = 60.0
|
||||||
var responseRateLimitMaxResponses: Int = 8
|
var responseRateLimitMaxResponses: Int = 8
|
||||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
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
|
private let myPeerID: PeerID
|
||||||
@@ -102,6 +107,10 @@ final class GossipSyncManager {
|
|||||||
private var fragments = PacketStore()
|
private var fragments = PacketStore()
|
||||||
private var fileTransfers = PacketStore()
|
private var fileTransfers = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
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
|
private var archiveDirty = false
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
@@ -130,6 +139,9 @@ final class GossipSyncManager {
|
|||||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
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 {
|
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
|
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 {
|
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
types.formUnion(.fileTransfer)
|
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 {
|
if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
|
||||||
types.formUnion(.board)
|
types.formUnion(.board)
|
||||||
}
|
}
|
||||||
@@ -186,9 +201,15 @@ final class GossipSyncManager {
|
|||||||
// messages get the long town-crier window; fragments, file transfers and
|
// messages get the long town-crier window; fragments, file transfers and
|
||||||
// announces keep the short one.
|
// announces keep the short one.
|
||||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
let maxAgeSeconds = packet.type == MessageType.message.rawValue
|
let maxAgeSeconds: TimeInterval
|
||||||
? config.publicMessageMaxAgeSeconds
|
switch packet.type {
|
||||||
: config.maxMessageAgeSeconds
|
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 nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||||
|
|
||||||
@@ -241,6 +262,29 @@ final class GossipSyncManager {
|
|||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
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:
|
default:
|
||||||
break
|
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) {
|
if requestedTypes.contains(.boardPost) {
|
||||||
// The board store already filters to live posts and tombstones;
|
// The board store already filters to live posts and tombstones;
|
||||||
// no freshness window applies (posts sync until their own expiry).
|
// no freshness window applies (posts sync until their own expiry).
|
||||||
@@ -444,6 +505,11 @@ final class GossipSyncManager {
|
|||||||
if types.contains(.fileTransfer) {
|
if types.contains(.fileTransfer) {
|
||||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
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) {
|
if types.contains(.boardPost) {
|
||||||
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
||||||
}
|
}
|
||||||
@@ -463,6 +529,8 @@ final class GossipSyncManager {
|
|||||||
cap = max(1, config.fragmentCapacity)
|
cap = max(1, config.fragmentCapacity)
|
||||||
} else if types == .fileTransfer {
|
} else if types == .fileTransfer {
|
||||||
cap = max(1, config.fileTransferCapacity)
|
cap = max(1, config.fileTransferCapacity)
|
||||||
|
} else if types == .prekeyBundle {
|
||||||
|
cap = max(1, config.prekeyBundleCapacity)
|
||||||
} else if types == .board {
|
} else if types == .board {
|
||||||
cap = max(1, config.boardCapacity)
|
cap = max(1, config.boardCapacity)
|
||||||
} else {
|
} else {
|
||||||
@@ -506,6 +574,9 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
fragments.removeExpired(isFresh: isPacketFresh)
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
|
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||||
|
isPacketFresh(pair.packet)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Archive (public message persistence)
|
// MARK: - Archive (public message persistence)
|
||||||
@@ -596,6 +667,8 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func removeState(for peerID: PeerID) {
|
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)
|
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||||
let messageCountBefore = messages.packets.count
|
let messageCountBefore = messages.packets.count
|
||||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
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 {
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||||
// would waste airtime and extend their lifetime.
|
// would waste airtime and extend their lifetime.
|
||||||
case .nostrCarrier: return nil
|
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
|
// 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.
|
// known type, so old clients ignore board rounds instead of choking.
|
||||||
case 8: return .boardPost
|
case 8: return .boardPost
|
||||||
|
case 9: return .prekeyBundle
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -74,6 +80,7 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||||
static let board = SyncTypeFlags(messageTypes: [.boardPost])
|
static let board = SyncTypeFlags(messageTypes: [.boardPost])
|
||||||
|
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
||||||
|
|
||||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
func clearCurrentPublicTimeline() {
|
func clearCurrentPublicTimeline() {
|
||||||
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
|
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) {
|
Task.detached(priority: .utility) {
|
||||||
// Skipped under tests: the test process shares the user's real
|
// Skipped under tests: the test process shares the user's real
|
||||||
// ~/Library/Application Support/files tree, and this detached
|
// ~/Library/Application Support/files tree, and this detached
|
||||||
|
|||||||
@@ -1209,6 +1209,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
GossipMessageArchive.wipeDefault()
|
GossipMessageArchive.wipeDefault()
|
||||||
StoreAndForwardMetrics.shared.reset()
|
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
|
// Drop bulletin-board posts and tombstones (memory and disk); board
|
||||||
// posts are signed with our identity key and persist for days.
|
// posts are signed with our identity key and persist for days.
|
||||||
BoardStore.shared.wipe()
|
BoardStore.shared.wipe()
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
//
|
||||||
|
// PrekeyEndToEndTests.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 CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// Forward-secret courier flow through real BLEService instances: Bob gossips
|
||||||
|
/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time
|
||||||
|
/// prekey instead of Bob's static key, Carol carries the opaque envelope, and
|
||||||
|
/// Bob opens it with the matching prekey private.
|
||||||
|
struct PrekeyEndToEndTests {
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private final class PacketTap {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var packets: [BitchatPacket] = []
|
||||||
|
|
||||||
|
func record(_ packet: BitchatPacket) {
|
||||||
|
lock.lock(); packets.append(packet); lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func first(ofType type: MessageType) -> BitchatPacket? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return packets.first { $0.type == type.rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NoiseCaptureDelegate: BitchatDelegate {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
|
||||||
|
|
||||||
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
|
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return payloads
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused BitchatDelegate requirements.
|
||||||
|
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||||
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeService() -> BLEService {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let service = BLEService(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
|
||||||
|
identityManager: MockIdentityManager(keychain),
|
||||||
|
initializeBluetoothManagers: false
|
||||||
|
)
|
||||||
|
service.courierStore = CourierStore(persistsToDisk: false)
|
||||||
|
service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false)
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data("ping".utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast announce + prekey bundle from `peer` and return both packets.
|
||||||
|
private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) {
|
||||||
|
peer.sendBroadcastAnnounce()
|
||||||
|
let published = await TestHelpers.waitUntil(
|
||||||
|
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(published)
|
||||||
|
return (
|
||||||
|
announce: try #require(tap.first(ofType: .announce)),
|
||||||
|
bundle: try #require(tap.first(ofType: .prekeyBundle))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@Test func prekeySealedMailTravelsViaCourierAndOpens() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
let bob = makeService()
|
||||||
|
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||||
|
|
||||||
|
let bobDelegate = NoiseCaptureDelegate()
|
||||||
|
bob.delegate = bobDelegate
|
||||||
|
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
let carolOut = PacketTap()
|
||||||
|
carol._test_onOutboundPacket = carolOut.record
|
||||||
|
let bobOut = PacketTap()
|
||||||
|
bob._test_onOutboundPacket = bobOut.record
|
||||||
|
|
||||||
|
preseedConnectedPeer(carol, in: alice)
|
||||||
|
|
||||||
|
// 1. While Bob is still around, Alice hears his verified announce
|
||||||
|
// (binding his signing key) and his gossiped prekey bundle.
|
||||||
|
let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||||
|
alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let cached = await TestHelpers.waitUntil(
|
||||||
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(cached)
|
||||||
|
|
||||||
|
// 2. Bob goes dark; Alice seals for him and deposits with Carol.
|
||||||
|
// The envelope must be v2: sealed to a one-time prekey.
|
||||||
|
#expect(alice.sendCourierMessage(
|
||||||
|
"burn after reading",
|
||||||
|
messageID: "prekey-msg-1",
|
||||||
|
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||||
|
via: [carol.myPeerID]
|
||||||
|
))
|
||||||
|
let deposited = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(deposited)
|
||||||
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload))
|
||||||
|
#expect(sealedEnvelope.prekeyID != nil)
|
||||||
|
|
||||||
|
// 3. Carol carries it (opaque, prekey or not).
|
||||||
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
|
let carried = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(carried)
|
||||||
|
|
||||||
|
// 4. Bob resurfaces near Carol → handover, and the v2 discriminator
|
||||||
|
// survives the store round-trip.
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let reannounced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(reannounced)
|
||||||
|
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
|
||||||
|
carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(handedOver)
|
||||||
|
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||||
|
let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload))
|
||||||
|
#expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID)
|
||||||
|
|
||||||
|
// 5. Bob opens it with the matching one-time prekey private and sees
|
||||||
|
// Alice as the authenticated sender.
|
||||||
|
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||||
|
let received = await TestHelpers.waitUntil(
|
||||||
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(received)
|
||||||
|
|
||||||
|
let delivered = try #require(bobDelegate.snapshot().first)
|
||||||
|
#expect(delivered.type == .privateMessage)
|
||||||
|
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
|
||||||
|
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||||
|
#expect(message.messageID == "prekey-msg-1")
|
||||||
|
#expect(message.content == "burn after reading")
|
||||||
|
|
||||||
|
// 6. Redelivery tolerance: the same envelope arriving via another
|
||||||
|
// packet (spray-and-wait) still opens inside the grace window.
|
||||||
|
let redelivery = BitchatPacket(
|
||||||
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
|
senderID: Data(hexString: carol.myPeerID.id) ?? Data(),
|
||||||
|
recipientID: handoverPacket.recipientID,
|
||||||
|
timestamp: handoverPacket.timestamp + 1,
|
||||||
|
payload: handoverPacket.payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
|
||||||
|
let redelivered = await TestHelpers.waitUntil(
|
||||||
|
{ bobDelegate.snapshot().count == 2 },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(redelivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func withoutBundleSealingFallsBackToStatic() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let bob = makeService()
|
||||||
|
|
||||||
|
let bobDelegate = NoiseCaptureDelegate()
|
||||||
|
bob.delegate = bobDelegate
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
|
||||||
|
// Bob is a connected "courier" who happens to be the recipient: the
|
||||||
|
// envelope reaches him directly and the recipient tag matches.
|
||||||
|
preseedConnectedPeer(bob, in: alice)
|
||||||
|
|
||||||
|
// Alice never saw a bundle for Bob → v1 static-sealed envelope.
|
||||||
|
#expect(alice.sendCourierMessage(
|
||||||
|
"plain static seal",
|
||||||
|
messageID: "static-msg-1",
|
||||||
|
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||||
|
via: [bob.myPeerID]
|
||||||
|
))
|
||||||
|
let deposited = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(deposited)
|
||||||
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
let envelope = try #require(CourierEnvelope.decode(depositPacket.payload))
|
||||||
|
#expect(envelope.prekeyID == nil)
|
||||||
|
|
||||||
|
// Bob opens the v1 envelope exactly as before the prekey change.
|
||||||
|
// (No preseed: Alice is absent from Bob's mesh, so the sender should
|
||||||
|
// resolve to her full noise-key ID like the courier case.)
|
||||||
|
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
|
||||||
|
let received = await TestHelpers.waitUntil(
|
||||||
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(received)
|
||||||
|
let delivered = try #require(bobDelegate.snapshot().first)
|
||||||
|
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
|
||||||
|
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||||
|
#expect(message.content == "plain static seal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unverifiableBundleIsIgnored() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let bob = makeService()
|
||||||
|
|
||||||
|
let bobOut = PacketTap()
|
||||||
|
bob._test_onOutboundPacket = bobOut.record
|
||||||
|
|
||||||
|
// Alice receives Bob's bundle but never saw a verified announce, so
|
||||||
|
// no signing key is bound to his noise key: the bundle must not be
|
||||||
|
// cached or enter Alice's gossip store.
|
||||||
|
let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
|
||||||
|
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let cached = await TestHelpers.waitUntil(
|
||||||
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func forgedBundleSignatureIsRejected() 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)
|
||||||
|
|
||||||
|
// Mallory tampers with the gossiped bundle in flight.
|
||||||
|
let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload))
|
||||||
|
var forgedSignature = bundle.signature
|
||||||
|
forgedSignature[0] ^= 0x01
|
||||||
|
let forged = PrekeyBundle(
|
||||||
|
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
|
||||||
|
prekeys: bundle.prekeys,
|
||||||
|
generatedAt: bundle.generatedAt,
|
||||||
|
signature: forgedSignature
|
||||||
|
)
|
||||||
|
let forgedPacket = BitchatPacket(
|
||||||
|
type: MessageType.prekeyBundle.rawValue,
|
||||||
|
senderID: bundlePacket.senderID,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: bundlePacket.timestamp,
|
||||||
|
payload: try #require(forged.encode()),
|
||||||
|
signature: bundlePacket.signature,
|
||||||
|
ttl: bundlePacket.ttl
|
||||||
|
)
|
||||||
|
alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let cached = await TestHelpers.waitUntil(
|
||||||
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func verifiedBundleEntersGossipStore() 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)
|
||||||
|
alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let cached = await TestHelpers.waitUntil(
|
||||||
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(cached)
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 1
|
config.messageSyncIntervalSeconds = 1
|
||||||
config.fragmentSyncIntervalSeconds = 1
|
config.fragmentSyncIntervalSeconds = 1
|
||||||
config.fileTransferSyncIntervalSeconds = 1
|
config.fileTransferSyncIntervalSeconds = 1
|
||||||
|
config.prekeyBundleSyncIntervalSeconds = 1
|
||||||
config.maintenanceIntervalSeconds = 0
|
config.maintenanceIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let requestSyncManager = RequestSyncManager()
|
||||||
@@ -195,19 +196,22 @@ struct GossipSyncManagerTests {
|
|||||||
manager._performMaintenanceSynchronously(now: Date())
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
|
|
||||||
// One request per due schedule so each type group gets the full
|
// One request per due schedule so each type group gets the full
|
||||||
// filter capacity: publicMessages, fragment, and fileTransfer.
|
// filter capacity: publicMessages, fragment, fileTransfer, and
|
||||||
|
// prekeyBundle.
|
||||||
let sentPackets = delegate.packets
|
let sentPackets = delegate.packets
|
||||||
#expect(sentPackets.count == 3)
|
#expect(sentPackets.count == 4)
|
||||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
||||||
#expect(decoded.count == 3)
|
#expect(decoded.count == 4)
|
||||||
let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) }
|
let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) }
|
||||||
#expect(allTypes.contains(.announce))
|
#expect(allTypes.contains(.announce))
|
||||||
#expect(allTypes.contains(.message))
|
#expect(allTypes.contains(.message))
|
||||||
#expect(allTypes.contains(.fragment))
|
#expect(allTypes.contains(.fragment))
|
||||||
#expect(allTypes.contains(.fileTransfer))
|
#expect(allTypes.contains(.fileTransfer))
|
||||||
|
#expect(allTypes.contains(.prekeyBundle))
|
||||||
#expect(decoded.contains { $0.types == .publicMessages })
|
#expect(decoded.contains { $0.types == .publicMessages })
|
||||||
#expect(decoded.contains { $0.types == .fragment })
|
#expect(decoded.contains { $0.types == .fragment })
|
||||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||||
|
#expect(decoded.contains { $0.types == .prekeyBundle })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func truncatedFilterCarriesSinceCursor() throws {
|
@Test func truncatedFilterCarriesSinceCursor() throws {
|
||||||
@@ -292,6 +296,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let requestSyncManager = RequestSyncManager()
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||||
@@ -362,6 +367,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let requestSyncManager = RequestSyncManager()
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||||
@@ -401,6 +407,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
config.responseRateLimitMaxResponses = 1
|
config.responseRateLimitMaxResponses = 1
|
||||||
config.responseRateLimitWindowSeconds = 60
|
config.responseRateLimitWindowSeconds = 60
|
||||||
|
|
||||||
@@ -455,6 +462,7 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(types.contains(.message))
|
#expect(types.contains(.message))
|
||||||
#expect(types.contains(.fragment))
|
#expect(types.contains(.fragment))
|
||||||
#expect(types.contains(.fileTransfer))
|
#expect(types.contains(.fileTransfer))
|
||||||
|
#expect(types.contains(.prekeyBundle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
||||||
@@ -533,6 +541,7 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let requestSyncManager = RequestSyncManager()
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||||
@@ -579,7 +588,6 @@ struct GossipSyncManagerTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
|
||||||
let requestSyncManager = RequestSyncManager()
|
let requestSyncManager = RequestSyncManager()
|
||||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||||
let delegate = RecordingDelegate()
|
let delegate = RecordingDelegate()
|
||||||
@@ -599,6 +607,93 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(ids == Set([stalledID]))
|
#expect(ids == Set([stalledID]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
|
||||||
|
var config = GossipSyncManager.Config()
|
||||||
|
config.messageSyncIntervalSeconds = 0
|
||||||
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
|
config.stalePeerCleanupIntervalSeconds = 0
|
||||||
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
|
||||||
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
|
||||||
|
let delegate = RecordingDelegate()
|
||||||
|
manager.delegate = delegate
|
||||||
|
|
||||||
|
// 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: try #require(bundle.encode()),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
manager.onPublicPacketSeen(bundlePacket)
|
||||||
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
|
#expect(manager._hasPrekeyBundle(for: senderPeer))
|
||||||
|
|
||||||
|
// Bundles outlive the owner's announce: a leave plus stale cleanup
|
||||||
|
// must not drop them (they exist to reach offline owners).
|
||||||
|
manager.removeAnnouncementForPeer(senderPeer)
|
||||||
|
manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1))
|
||||||
|
#expect(manager._hasPrekeyBundle(for: senderPeer))
|
||||||
|
|
||||||
|
// And a .prekeyBundle sync request is answered with the stored packet.
|
||||||
|
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
|
||||||
|
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||||
|
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||||
|
let served = try #require(delegate.packets.first)
|
||||||
|
#expect(served.type == MessageType.prekeyBundle.rawValue)
|
||||||
|
#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
|
// MARK: - Archive persistence
|
||||||
|
|
||||||
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
//
|
||||||
|
// NoisePrekeyTests.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
|
||||||
|
|
||||||
|
/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys
|
||||||
|
/// instead of the recipient's identity static key.
|
||||||
|
struct NoisePrekeyTests {
|
||||||
|
|
||||||
|
@Test func sealAndOpenRoundTrip() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
let prekey = try #require(bundle.prekeys.first)
|
||||||
|
|
||||||
|
let payload = Data("meet at the north gate".utf8)
|
||||||
|
let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
|
||||||
|
|
||||||
|
let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||||
|
#expect(opened.payload == payload)
|
||||||
|
// The X pattern authenticates the sender: Bob learns Alice's real static key.
|
||||||
|
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wrongPrekeyIDCannotOpen() throws {
|
||||||
|
// The prologue binds the ciphertext to a specific prekey ID; opening
|
||||||
|
// with a different (existing) prekey must fail.
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
#expect(bundle.prekeys.count >= 2)
|
||||||
|
|
||||||
|
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0])
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unknownPrekeyIDThrows() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
let prekey = try #require(bundle.prekeys.first)
|
||||||
|
|
||||||
|
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
|
||||||
|
#expect(throws: NoiseEncryptionError.unknownPrekey) {
|
||||||
|
_ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wrongRecipientCannotOpen() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let carol = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bobBundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
// Ensure Carol holds a prekey under the same ID as Bob's.
|
||||||
|
_ = try #require(carol.currentPrekeyBundle())
|
||||||
|
let prekey = try #require(bobBundle.prekeys.first)
|
||||||
|
|
||||||
|
let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tamperedCiphertextFailsToOpen() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
let prekey = try #require(bundle.prekeys.first)
|
||||||
|
|
||||||
|
var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
|
||||||
|
sealed[sealed.count - 1] ^= 0x01
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws {
|
||||||
|
// Spray-and-wait can deliver the same ciphertext via several couriers
|
||||||
|
// days apart; the consumed private survives a grace window for that.
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
let prekey = try #require(bundle.prekeys.first)
|
||||||
|
|
||||||
|
let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey)
|
||||||
|
let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||||
|
let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
|
||||||
|
#expect(first.payload == second.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func prekeyAndStaticSealsAreNotInterchangeable() throws {
|
||||||
|
// Domain-separated prologues: a static-sealed envelope must not open
|
||||||
|
// via the prekey path and vice versa, even with matching key material.
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
let prekey = try #require(bundle.prekeys.first)
|
||||||
|
|
||||||
|
let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey)
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try bob.openCourierPayload(prekeySealed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sealRejectsInvalidPrekeyPublicKey() {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32)))
|
||||||
|
}
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sealsAreNotLinkableAcrossSends() throws {
|
||||||
|
// Fresh ephemeral per seal even to the same prekey.
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(bob.currentPrekeyBundle())
|
||||||
|
let prekey = try #require(bundle.prekeys.first)
|
||||||
|
let payload = Data("same message".utf8)
|
||||||
|
|
||||||
|
let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
|
||||||
|
let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
|
||||||
|
#expect(a != b)
|
||||||
|
#expect(a.prefix(32) != b.prefix(32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local one-time prekey lifecycle: batch generation, consumption, the 48h
|
||||||
|
/// redelivery grace window, replenishment, and the panic wipe.
|
||||||
|
struct LocalPrekeyStoreTests {
|
||||||
|
|
||||||
|
private final class Clock {
|
||||||
|
var now: Date
|
||||||
|
init(_ now: Date = Date()) { self.now = now }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore {
|
||||||
|
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()
|
||||||
|
#expect(prekeys.count == LocalPrekeyStore.Policy.batchSize)
|
||||||
|
#expect(generatedAt > 0)
|
||||||
|
#expect(Set(prekeys.map(\.id)).count == prekeys.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() {
|
||||||
|
let clock = Clock()
|
||||||
|
let store = makeStore(clock: clock)
|
||||||
|
let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
|
||||||
|
|
||||||
|
// Consuming down to the threshold does not regenerate...
|
||||||
|
let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold
|
||||||
|
for prekey in initial.dropLast(keepUnconsumed) {
|
||||||
|
store.markConsumed(prekey.id)
|
||||||
|
}
|
||||||
|
#expect(!store.replenishIfNeeded())
|
||||||
|
#expect(store.unconsumedCount == keepUnconsumed)
|
||||||
|
|
||||||
|
// ...one more consumption does, topping back up to a full batch with
|
||||||
|
// a newer generation stamp.
|
||||||
|
clock.now = clock.now.addingTimeInterval(60)
|
||||||
|
store.markConsumed(initial[initial.count - keepUnconsumed].id)
|
||||||
|
#expect(store.replenishIfNeeded())
|
||||||
|
let (replenished, secondGeneratedAt) = store.currentBundlePrekeys()
|
||||||
|
#expect(replenished.count == LocalPrekeyStore.Policy.batchSize)
|
||||||
|
#expect(secondGeneratedAt > firstGeneratedAt)
|
||||||
|
// Surviving unconsumed prekeys stay in the fresh bundle.
|
||||||
|
let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id))
|
||||||
|
#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)
|
||||||
|
let (prekeys, _) = store.currentBundlePrekeys()
|
||||||
|
let id = prekeys[0].id
|
||||||
|
|
||||||
|
store.markConsumed(id)
|
||||||
|
// Within the grace window: still retrievable for redeliveries.
|
||||||
|
clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
|
||||||
|
#expect(store.privateKey(for: id) != nil)
|
||||||
|
|
||||||
|
// Past the grace window: gone (even before replenish prunes it).
|
||||||
|
clock.now = clock.now.addingTimeInterval(120)
|
||||||
|
#expect(store.privateKey(for: id) == nil)
|
||||||
|
store.replenishIfNeeded()
|
||||||
|
#expect(store.privateKey(for: id) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func persistsAcrossInstances() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let clock = Clock()
|
||||||
|
let first = LocalPrekeyStore(keychain: keychain, now: { clock.now })
|
||||||
|
let (prekeys, generatedAt) = first.currentBundlePrekeys()
|
||||||
|
first.markConsumed(prekeys[0].id)
|
||||||
|
|
||||||
|
let second = LocalPrekeyStore(keychain: keychain, now: { clock.now })
|
||||||
|
let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys()
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wipeRemovesEverything() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let store = LocalPrekeyStore(keychain: keychain)
|
||||||
|
let (prekeys, _) = store.currentBundlePrekeys()
|
||||||
|
store.wipe()
|
||||||
|
#expect(store.privateKey(for: prekeys[0].id) == nil)
|
||||||
|
#expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// PrekeyBundleTests.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
|
||||||
|
|
||||||
|
/// Wire format and signature binding for gossiped one-time prekey bundles.
|
||||||
|
struct PrekeyBundleTests {
|
||||||
|
|
||||||
|
private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] {
|
||||||
|
(0..<count).map { index in
|
||||||
|
PrekeyBundle.Prekey(
|
||||||
|
id: UInt32(index),
|
||||||
|
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wire format
|
||||||
|
|
||||||
|
@Test func encodeDecodeRoundTrip() throws {
|
||||||
|
let bundle = PrekeyBundle(
|
||||||
|
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
|
||||||
|
prekeys: makePrekeys(8),
|
||||||
|
generatedAt: 1_234_567_890_123,
|
||||||
|
signature: Data(repeating: 0xAB, count: PrekeyBundle.signatureLength)
|
||||||
|
)
|
||||||
|
let encoded = try #require(bundle.encode())
|
||||||
|
let decoded = try #require(PrekeyBundle.decode(encoded))
|
||||||
|
#expect(decoded == bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeSkipsUnknownTLVs() throws {
|
||||||
|
let bundle = PrekeyBundle(
|
||||||
|
noiseStaticPublicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
|
||||||
|
prekeys: makePrekeys(2),
|
||||||
|
generatedAt: 42,
|
||||||
|
signature: Data(repeating: 0x01, count: PrekeyBundle.signatureLength)
|
||||||
|
)
|
||||||
|
var encoded = try #require(bundle.encode())
|
||||||
|
// Unknown TLV 0x7F appended by a future client.
|
||||||
|
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03])
|
||||||
|
let decoded = try #require(PrekeyBundle.decode(encoded))
|
||||||
|
#expect(decoded == bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func encodeRejectsInvalidShapes() {
|
||||||
|
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||||
|
let signature = Data(repeating: 0, count: PrekeyBundle.signatureLength)
|
||||||
|
// No prekeys.
|
||||||
|
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: [], generatedAt: 1, signature: signature).encode() == nil)
|
||||||
|
// Too many prekeys.
|
||||||
|
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(PrekeyBundle.maxPrekeys + 1), generatedAt: 1, signature: signature).encode() == nil)
|
||||||
|
// Wrong owner key length.
|
||||||
|
#expect(PrekeyBundle(noiseStaticPublicKey: Data(repeating: 1, count: 8), prekeys: makePrekeys(1), generatedAt: 1, signature: signature).encode() == nil)
|
||||||
|
// Wrong signature length.
|
||||||
|
#expect(PrekeyBundle(noiseStaticPublicKey: key, prekeys: makePrekeys(1), generatedAt: 1, signature: Data(repeating: 0, count: 32)).encode() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeRejectsDuplicatePrekeyIDs() throws {
|
||||||
|
let key = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||||
|
let prekey = makePrekeys(1)[0]
|
||||||
|
let bundle = PrekeyBundle(
|
||||||
|
noiseStaticPublicKey: key,
|
||||||
|
prekeys: [prekey, PrekeyBundle.Prekey(id: prekey.id, publicKey: key)],
|
||||||
|
generatedAt: 1,
|
||||||
|
signature: Data(repeating: 0, count: PrekeyBundle.signatureLength)
|
||||||
|
)
|
||||||
|
let encoded = try #require(bundle.encode())
|
||||||
|
#expect(PrekeyBundle.decode(encoded) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Signature binding
|
||||||
|
|
||||||
|
@Test func signVerifyRoundTrip() throws {
|
||||||
|
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(owner.currentPrekeyBundle())
|
||||||
|
|
||||||
|
#expect(bundle.noiseStaticPublicKey == owner.getStaticPublicKeyData())
|
||||||
|
#expect(bundle.prekeys.count == PrekeyBundle.maxPrekeys)
|
||||||
|
#expect(owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: owner.getSigningPublicKeyData()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func forgedSignatureFailsVerification() throws {
|
||||||
|
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(owner.currentPrekeyBundle())
|
||||||
|
|
||||||
|
var forgedSignature = bundle.signature
|
||||||
|
forgedSignature[0] ^= 0x01
|
||||||
|
let forged = PrekeyBundle(
|
||||||
|
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
|
||||||
|
prekeys: bundle.prekeys,
|
||||||
|
generatedAt: bundle.generatedAt,
|
||||||
|
signature: forgedSignature
|
||||||
|
)
|
||||||
|
#expect(!owner.verifyPrekeyBundleSignature(forged, signingPublicKey: owner.getSigningPublicKeyData()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tamperedContentsFailVerification() throws {
|
||||||
|
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(owner.currentPrekeyBundle())
|
||||||
|
|
||||||
|
// Mallory swaps in her own prekey but keeps the owner's signature.
|
||||||
|
var prekeys = bundle.prekeys
|
||||||
|
prekeys[0] = PrekeyBundle.Prekey(
|
||||||
|
id: prekeys[0].id,
|
||||||
|
publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
|
||||||
|
)
|
||||||
|
let tampered = PrekeyBundle(
|
||||||
|
noiseStaticPublicKey: bundle.noiseStaticPublicKey,
|
||||||
|
prekeys: prekeys,
|
||||||
|
generatedAt: bundle.generatedAt,
|
||||||
|
signature: bundle.signature
|
||||||
|
)
|
||||||
|
#expect(!owner.verifyPrekeyBundleSignature(tampered, signingPublicKey: owner.getSigningPublicKeyData()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wrongSigningKeyFailsVerification() throws {
|
||||||
|
let owner = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let other = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bundle = try #require(owner.currentPrekeyBundle())
|
||||||
|
|
||||||
|
#expect(!owner.verifyPrekeyBundleSignature(bundle, signingPublicKey: other.getSigningPublicKeyData()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ struct GossipSyncBoardTests {
|
|||||||
config.messageSyncIntervalSeconds = 0
|
config.messageSyncIntervalSeconds = 0
|
||||||
config.fragmentSyncIntervalSeconds = 0
|
config.fragmentSyncIntervalSeconds = 0
|
||||||
config.fileTransferSyncIntervalSeconds = 0
|
config.fileTransferSyncIntervalSeconds = 0
|
||||||
|
config.prekeyBundleSyncIntervalSeconds = 0
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,18 +38,19 @@ struct SyncTypeFlagsBoardTests {
|
|||||||
/// decode path accepts the bytes and simply maps unknown bits to no
|
/// decode path accepts the bytes and simply maps unknown bits to no
|
||||||
/// message type, so a board-only request reads as "nothing I can serve".
|
/// message type, so a board-only request reads as "nothing I can serve".
|
||||||
@Test func unknownBitsDecodeToNoTypes() throws {
|
@Test func unknownBitsDecodeToNoTypes() throws {
|
||||||
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
|
// Bits 10-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle); a
|
||||||
// must decode without error and yield no known types.
|
// future (or unknown) two-byte bitfield must decode without error and
|
||||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
|
// yield no known types.
|
||||||
|
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFC])))
|
||||||
#expect(decoded.toMessageTypes().isEmpty)
|
#expect(decoded.toMessageTypes().isEmpty)
|
||||||
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
|
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle] {
|
||||||
#expect(!decoded.contains(type))
|
#expect(!decoded.contains(type))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
|
||||||
// Known low-byte flags survive alongside unknown high bits.
|
// Known low-byte flags survive alongside unknown high bits (10-15).
|
||||||
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
|
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC])))
|
||||||
#expect(decoded.contains(.announce))
|
#expect(decoded.contains(.announce))
|
||||||
#expect(decoded.contains(.message))
|
#expect(decoded.contains(.message))
|
||||||
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ struct SyncTypeFlagsTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func decodeDropsPhantomBits() {
|
@Test func decodeDropsPhantomBits() {
|
||||||
// Bits 9+ map to no message type (bit 8 is boardPost). They must not
|
// Bits 10+ map to no message type (bit 8 = boardPost, bit 9 =
|
||||||
// survive decode as phantom membership.
|
// prekeyBundle). They must not survive decode as phantom membership.
|
||||||
let phantom = Data([0x00, 0xFE]) // bits 9..15 set, no known type
|
let phantom = Data([0x00, 0xFC]) // bits 10..15 set, no known type
|
||||||
let decoded = SyncTypeFlags.decode(phantom)
|
let decoded = SyncTypeFlags.decode(phantom)
|
||||||
#expect(decoded?.rawValue == 0)
|
#expect(decoded?.rawValue == 0)
|
||||||
#expect(decoded?.toMessageTypes().isEmpty == true)
|
#expect(decoded?.toMessageTypes().isEmpty == true)
|
||||||
@@ -23,16 +23,17 @@ struct SyncTypeFlagsTests {
|
|||||||
|
|
||||||
@Test func boardBitSurvivesDecode() {
|
@Test func boardBitSurvivesDecode() {
|
||||||
// Bit 8 maps to boardPost and spills the field into a second byte;
|
// Bit 8 maps to boardPost and spills the field into a second byte;
|
||||||
// it must survive decode while the phantom high bits are stripped.
|
// it must survive decode while the phantom high bits (10+) are
|
||||||
let mixed = Data([0x00, 0xFF]) // bit 8 (board) known, bits 9..15 phantom
|
// stripped. Bit 9 (prekeyBundle) is cleared to isolate the board bit.
|
||||||
|
let mixed = Data([0x00, 0xFD]) // bit 8 (board) known, bits 10..15 phantom
|
||||||
let decoded = SyncTypeFlags.decode(mixed)
|
let decoded = SyncTypeFlags.decode(mixed)
|
||||||
#expect(decoded?.contains(.board) == true)
|
#expect(decoded?.contains(.board) == true)
|
||||||
#expect(decoded?.rawValue == 0b1_0000_0000)
|
#expect(decoded?.rawValue == 0b1_0000_0000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func phantomBitsAreStrippedButKnownBitsSurvive() {
|
@Test func phantomBitsAreStrippedButKnownBitsSurvive() {
|
||||||
// Low byte = announce(0) + message(1); high byte bits 9+ are phantom.
|
// Low byte = announce(0) + message(1); high byte bits 10+ are phantom.
|
||||||
let mixed = Data([0b0000_0011, 0xFE])
|
let mixed = Data([0b0000_0011, 0xFC])
|
||||||
let decoded = SyncTypeFlags.decode(mixed)
|
let decoded = SyncTypeFlags.decode(mixed)
|
||||||
#expect(decoded?.contains(.announce) == true)
|
#expect(decoded?.contains(.announce) == true)
|
||||||
#expect(decoded?.contains(.message) == true)
|
#expect(decoded?.contains(.message) == true)
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public struct CourierEnvelope: Equatable {
|
|||||||
/// the holder may still hand to other couriers (binary split on each
|
/// the holder may still hand to other couriers (binary split on each
|
||||||
/// spray). 1 means carry-only — deliver to the recipient, never re-spray.
|
/// spray). 1 means carry-only — deliver to the recipient, never re-spray.
|
||||||
public let copies: UInt8
|
public let copies: UInt8
|
||||||
|
/// Seal-format discriminator: nil means v1 (ciphertext is one-way Noise X
|
||||||
|
/// to the recipient's *static* key); a value means v2 (Noise X to the
|
||||||
|
/// recipient's one-time prekey with this ID, forward secret). Encoded as
|
||||||
|
/// an optional TLV so v1 decoders skip it as unknown: an old client still
|
||||||
|
/// carries and hands over v2 envelopes opaquely, and when one is addressed
|
||||||
|
/// to it the static-key open simply fails and is dropped quietly.
|
||||||
|
public let prekeyID: UInt32?
|
||||||
|
|
||||||
public static let tagLength = 16
|
public static let tagLength = 16
|
||||||
/// Couriered messages are text-sized; media transfers are out of scope.
|
/// Couriered messages are text-sized; media transfers are out of scope.
|
||||||
@@ -43,18 +50,20 @@ public struct CourierEnvelope: Equatable {
|
|||||||
case expiry = 0x02
|
case expiry = 0x02
|
||||||
case ciphertext = 0x03
|
case ciphertext = 0x03
|
||||||
case copies = 0x04
|
case copies = 0x04
|
||||||
|
case prekeyID = 0x05
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1) {
|
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1, prekeyID: UInt32? = nil) {
|
||||||
self.recipientTag = recipientTag
|
self.recipientTag = recipientTag
|
||||||
self.expiry = expiry
|
self.expiry = expiry
|
||||||
self.ciphertext = ciphertext
|
self.ciphertext = ciphertext
|
||||||
self.copies = min(max(copies, 1), Self.maxCopies)
|
self.copies = min(max(copies, 1), Self.maxCopies)
|
||||||
|
self.prekeyID = prekeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same envelope with a different remaining copy budget.
|
/// The same envelope with a different remaining copy budget.
|
||||||
public func withCopies(_ copies: UInt8) -> CourierEnvelope {
|
public func withCopies(_ copies: UInt8) -> CourierEnvelope {
|
||||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isExpired: Bool {
|
public var isExpired: Bool {
|
||||||
@@ -97,6 +106,14 @@ public struct CourierEnvelope: Equatable {
|
|||||||
encoded.append(copies)
|
encoded.append(copies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Omitted for v1 static-sealed envelopes so they stay byte-identical
|
||||||
|
// to the pre-prekey wire format.
|
||||||
|
if let prekeyID {
|
||||||
|
encoded.append(TLVType.prekeyID.rawValue)
|
||||||
|
appendBE(UInt16(4), into: &encoded)
|
||||||
|
appendBE(prekeyID, into: &encoded)
|
||||||
|
}
|
||||||
|
|
||||||
return encoded
|
return encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +125,7 @@ public struct CourierEnvelope: Equatable {
|
|||||||
var expiry: UInt64?
|
var expiry: UInt64?
|
||||||
var ciphertext: Data?
|
var ciphertext: Data?
|
||||||
var copies: UInt8 = 1
|
var copies: UInt8 = 1
|
||||||
|
var prekeyID: UInt32?
|
||||||
|
|
||||||
while cursor < end {
|
while cursor < end {
|
||||||
let typeRaw = data[cursor]
|
let typeRaw = data[cursor]
|
||||||
@@ -133,6 +151,9 @@ public struct CourierEnvelope: Equatable {
|
|||||||
case .copies:
|
case .copies:
|
||||||
guard length == 1 else { return nil }
|
guard length == 1 else { return nil }
|
||||||
copies = value.first ?? 1
|
copies = value.first ?? 1
|
||||||
|
case .prekeyID:
|
||||||
|
guard length == 4 else { return nil }
|
||||||
|
prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||||
case nil:
|
case nil:
|
||||||
// Unknown TLV: skip for forward compatibility.
|
// Unknown TLV: skip for forward compatibility.
|
||||||
continue
|
continue
|
||||||
@@ -140,7 +161,7 @@ public struct CourierEnvelope: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
||||||
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recipient Tags
|
// MARK: - Recipient Tags
|
||||||
|
|||||||
@@ -16,15 +16,16 @@ public enum MessageType: UInt8 {
|
|||||||
case leave = 0x03 // "I'm leaving"
|
case leave = 0x03 // "I'm leaving"
|
||||||
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
|
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
|
||||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||||
|
|
||||||
// Noise encryption
|
// Noise encryption
|
||||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||||
|
|
||||||
// Fragmentation (simplified)
|
// Fragmentation (simplified)
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||||
|
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||||
|
|
||||||
// Mesh diagnostics
|
// Mesh diagnostics
|
||||||
case ping = 0x26 // Directed echo request (nonce + origin TTL)
|
case ping = 0x26 // Directed echo request (nonce + origin TTL)
|
||||||
@@ -46,6 +47,7 @@ public enum MessageType: UInt8 {
|
|||||||
case .fragment: return "fragment"
|
case .fragment: return "fragment"
|
||||||
case .fileTransfer: return "fileTransfer"
|
case .fileTransfer: return "fileTransfer"
|
||||||
case .boardPost: return "boardPost"
|
case .boardPost: return "boardPost"
|
||||||
|
case .prekeyBundle: return "prekeyBundle"
|
||||||
case .ping: return "ping"
|
case .ping: return "ping"
|
||||||
case .pong: return "pong"
|
case .pong: return "pong"
|
||||||
case .nostrCarrier: return "nostrCarrier"
|
case .nostrCarrier: return "nostrCarrier"
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// PrekeyBundle.swift
|
||||||
|
// BitFoundation
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// TLV payload for gossiped one-time prekey bundles (MessageType 0x24).
|
||||||
|
///
|
||||||
|
/// A bundle publishes a batch of one-time Curve25519 public prekeys bound to
|
||||||
|
/// the owner's Noise static key by an Ed25519 signature over domain-prefixed
|
||||||
|
/// canonical bytes. Anyone holding the owner's announce-verified signing key
|
||||||
|
/// can verify a bundle offline, which is what lets bundles spread and persist
|
||||||
|
/// mesh-wide via gossip sync while the owner is away. Senders seal courier
|
||||||
|
/// mail to one of these prekeys (one-way Noise X) instead of the owner's
|
||||||
|
/// long-lived static key, restoring forward secrecy for async first contact.
|
||||||
|
public struct PrekeyBundle: Equatable {
|
||||||
|
public struct Prekey: Equatable {
|
||||||
|
public let id: UInt32
|
||||||
|
/// Curve25519.KeyAgreement public key (32 bytes).
|
||||||
|
public let publicKey: Data
|
||||||
|
|
||||||
|
public init(id: UInt32, publicKey: Data) {
|
||||||
|
self.id = id
|
||||||
|
self.publicKey = publicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Noise static public key identifying whose prekeys these are (32 bytes).
|
||||||
|
public let noiseStaticPublicKey: Data
|
||||||
|
/// One-time prekeys, at most `maxPrekeys` per bundle.
|
||||||
|
public let prekeys: [Prekey]
|
||||||
|
/// Milliseconds since epoch when this bundle was generated; newer bundles
|
||||||
|
/// replace older ones for the same noise key.
|
||||||
|
public let generatedAt: UInt64
|
||||||
|
/// Ed25519 signature over `signableBytes()` by the owner's announce-bound
|
||||||
|
/// signing key.
|
||||||
|
public let signature: Data
|
||||||
|
|
||||||
|
public static let keyLength = 32
|
||||||
|
public static let signatureLength = 64
|
||||||
|
public static let maxPrekeys = 8
|
||||||
|
private static let prekeyEntryLength = 4 + keyLength
|
||||||
|
|
||||||
|
/// Domain separation for the bundle signature so it can never be confused
|
||||||
|
/// with announce or packet signatures.
|
||||||
|
private static let signingContext = Data("bitchat-prekey-bundle-v1".utf8)
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case noiseStaticPublicKey = 0x01
|
||||||
|
case prekeys = 0x02
|
||||||
|
case generatedAt = 0x03
|
||||||
|
case signature = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(noiseStaticPublicKey: Data, prekeys: [Prekey], generatedAt: UInt64, signature: Data) {
|
||||||
|
self.noiseStaticPublicKey = noiseStaticPublicKey
|
||||||
|
self.prekeys = prekeys
|
||||||
|
self.generatedAt = generatedAt
|
||||||
|
self.signature = signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical bytes covered by the Ed25519 signature: domain context,
|
||||||
|
/// owner key, prekey count, each (id, key) pair, and the generation time.
|
||||||
|
/// Encoders and verifiers must derive these identically.
|
||||||
|
public func signableBytes() -> Data {
|
||||||
|
var out = Data()
|
||||||
|
out.reserveCapacity(1 + Self.signingContext.count + Self.keyLength + 1
|
||||||
|
+ prekeys.count * Self.prekeyEntryLength + 8)
|
||||||
|
out.append(UInt8(min(Self.signingContext.count, 255)))
|
||||||
|
out.append(Self.signingContext.prefix(255))
|
||||||
|
out.append(paddedKey(noiseStaticPublicKey))
|
||||||
|
out.append(UInt8(min(prekeys.count, 255)))
|
||||||
|
for prekey in prekeys.prefix(255) {
|
||||||
|
appendBE(prekey.id, into: &out)
|
||||||
|
out.append(paddedKey(prekey.publicKey))
|
||||||
|
}
|
||||||
|
appendBE(generatedAt, into: &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode() -> Data? {
|
||||||
|
guard noiseStaticPublicKey.count == Self.keyLength,
|
||||||
|
signature.count == Self.signatureLength,
|
||||||
|
!prekeys.isEmpty, prekeys.count <= Self.maxPrekeys,
|
||||||
|
prekeys.allSatisfy({ $0.publicKey.count == Self.keyLength }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = Data()
|
||||||
|
entries.reserveCapacity(prekeys.count * Self.prekeyEntryLength)
|
||||||
|
for prekey in prekeys {
|
||||||
|
appendBE(prekey.id, into: &entries)
|
||||||
|
entries.append(prekey.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
encoded.reserveCapacity(4 * 3 + Self.keyLength + entries.count + 8 + Self.signatureLength)
|
||||||
|
|
||||||
|
encoded.append(TLVType.noiseStaticPublicKey.rawValue)
|
||||||
|
appendBE(UInt16(noiseStaticPublicKey.count), into: &encoded)
|
||||||
|
encoded.append(noiseStaticPublicKey)
|
||||||
|
|
||||||
|
encoded.append(TLVType.prekeys.rawValue)
|
||||||
|
appendBE(UInt16(entries.count), into: &encoded)
|
||||||
|
encoded.append(entries)
|
||||||
|
|
||||||
|
encoded.append(TLVType.generatedAt.rawValue)
|
||||||
|
appendBE(UInt16(8), into: &encoded)
|
||||||
|
appendBE(generatedAt, into: &encoded)
|
||||||
|
|
||||||
|
encoded.append(TLVType.signature.rawValue)
|
||||||
|
appendBE(UInt16(signature.count), into: &encoded)
|
||||||
|
encoded.append(signature)
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decode(_ data: Data) -> PrekeyBundle? {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
|
||||||
|
var noiseStaticPublicKey: Data?
|
||||||
|
var prekeys: [Prekey]?
|
||||||
|
var generatedAt: UInt64?
|
||||||
|
var signature: Data?
|
||||||
|
|
||||||
|
while cursor < end {
|
||||||
|
let typeRaw = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
|
||||||
|
guard data.distance(from: cursor, to: end) >= 2 else { return nil }
|
||||||
|
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||||
|
cursor = data.index(cursor, offsetBy: 2)
|
||||||
|
guard data.distance(from: cursor, to: end) >= length else { return nil }
|
||||||
|
let value = data[cursor..<data.index(cursor, offsetBy: length)]
|
||||||
|
cursor = data.index(cursor, offsetBy: length)
|
||||||
|
|
||||||
|
switch TLVType(rawValue: typeRaw) {
|
||||||
|
case .noiseStaticPublicKey:
|
||||||
|
guard length == keyLength else { return nil }
|
||||||
|
noiseStaticPublicKey = Data(value)
|
||||||
|
case .prekeys:
|
||||||
|
guard length > 0, length % prekeyEntryLength == 0,
|
||||||
|
length / prekeyEntryLength <= maxPrekeys else { return nil }
|
||||||
|
var parsed: [Prekey] = []
|
||||||
|
var entryStart = value.startIndex
|
||||||
|
while entryStart < value.endIndex {
|
||||||
|
let idEnd = value.index(entryStart, offsetBy: 4)
|
||||||
|
let id = value[entryStart..<idEnd].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||||
|
let keyEnd = value.index(idEnd, offsetBy: keyLength)
|
||||||
|
parsed.append(Prekey(id: id, publicKey: Data(value[idEnd..<keyEnd])))
|
||||||
|
entryStart = keyEnd
|
||||||
|
}
|
||||||
|
prekeys = parsed
|
||||||
|
case .generatedAt:
|
||||||
|
guard length == 8 else { return nil }
|
||||||
|
generatedAt = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
case .signature:
|
||||||
|
guard length == signatureLength else { return nil }
|
||||||
|
signature = Data(value)
|
||||||
|
case nil:
|
||||||
|
// Unknown TLV: skip for forward compatibility.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let noiseStaticPublicKey, let prekeys, let generatedAt, let signature,
|
||||||
|
!prekeys.isEmpty else { return nil }
|
||||||
|
// Duplicate prekey IDs would let one consumed ID shadow another.
|
||||||
|
guard Set(prekeys.map(\.id)).count == prekeys.count else { return nil }
|
||||||
|
return PrekeyBundle(
|
||||||
|
noiseStaticPublicKey: noiseStaticPublicKey,
|
||||||
|
prekeys: prekeys,
|
||||||
|
generatedAt: generatedAt,
|
||||||
|
signature: signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func paddedKey(_ key: Data) -> Data {
|
||||||
|
let fixed = key.prefix(Self.keyLength)
|
||||||
|
guard fixed.count < Self.keyLength else { return Data(fixed) }
|
||||||
|
return Data(fixed) + Data(repeating: 0, count: Self.keyLength - fixed.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
|
var big = value.bigEndian
|
||||||
|
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user