mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Merge remote-tracking branch 'origin/feat/prekeys' into feat/integration-all
# Conflicts: # bitchat/Sync/GossipSyncManager.swift
This commit is contained in:
@@ -3,5 +3,5 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = []
|
||||
static let localSupported: PeerCapabilities = [.prekeys]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .prekeyBundle:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ final class BLEService: NSObject {
|
||||
// Local-only store-and-forward counters; nil in unit tests.
|
||||
var sfMetrics: StoreAndForwardMetrics?
|
||||
|
||||
// Verified one-time prekey bundles gossiped by other peers, used to seal
|
||||
// courier mail forward-secretly. Injectable for tests.
|
||||
var prekeyBundleStore: PrekeyBundleStore = .shared
|
||||
// Throttle for re-broadcasting our own (unchanged) bundle; guarded by
|
||||
// collectionsQueue barriers.
|
||||
private var lastPrekeyBundleSentAt: Date?
|
||||
// Prekey bundles that arrived before their owner's verified announce bound
|
||||
// a signing key. The receive queue is concurrent, so a bundle can race
|
||||
// ahead of the announce it depends on; we retain the latest such bundle per
|
||||
// owner (bounded) and re-attempt attribution when the announce lands.
|
||||
// Guarded by collectionsQueue barriers.
|
||||
private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:]
|
||||
private static let pendingPrekeyBundleCap = 64
|
||||
|
||||
#if DEBUG
|
||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||
// packets between in-process service instances.
|
||||
@@ -290,7 +304,10 @@ final class BLEService: NSObject {
|
||||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
||||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
|
||||
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
|
||||
prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
|
||||
prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
|
||||
prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
|
||||
)
|
||||
|
||||
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
|
||||
@@ -333,6 +350,8 @@ final class BLEService: NSObject {
|
||||
ingressLinks.removeAll()
|
||||
recentTrafficTracker.removeAll()
|
||||
scheduledRelays.cancelAll()
|
||||
// Let the post-panic identity publish its fresh bundle promptly.
|
||||
lastPrekeyBundleSentAt = nil
|
||||
return transfers
|
||||
}
|
||||
|
||||
@@ -1303,6 +1322,10 @@ final class BLEService: NSObject {
|
||||
}
|
||||
// Ensure our own announce is included in sync state
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
|
||||
// Keep our prekey bundle riding alongside presence (throttled; the
|
||||
// send is a no-op when the bundle was refreshed recently).
|
||||
sendPrekeyBundle()
|
||||
}
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
@@ -1695,6 +1718,10 @@ extension BLEService {
|
||||
handleReceivedPacket(packet, from: fromPeerID)
|
||||
}
|
||||
|
||||
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
|
||||
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
|
||||
}
|
||||
|
||||
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
|
||||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||||
guard case .success = BLEIngressLinkRegistry.packetContext(
|
||||
@@ -2509,10 +2536,13 @@ extension BLEService {
|
||||
|
||||
// MARK: Courier Store-and-Forward
|
||||
|
||||
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
|
||||
/// the envelope to the given couriers for physical delivery. Returns false
|
||||
/// when no courier is connected, the payload cannot be built, or sealing
|
||||
/// fails; link writes are queued asynchronously after the envelope is ready.
|
||||
/// Seal `content` for the recipient and hand the envelope to the given
|
||||
/// couriers for physical delivery. When a verified one-time prekey bundle
|
||||
/// is cached for the recipient, sealing targets one of its prekeys
|
||||
/// (forward secret, envelope v2); otherwise it falls back to their static
|
||||
/// key (one-way Noise X, v1) exactly as before. Returns false when no
|
||||
/// courier is connected, the payload cannot be built, or sealing fails;
|
||||
/// link writes are queued asynchronously after the envelope is ready.
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
let connected = couriers.filter { isPeerConnected($0) }
|
||||
guard !connected.isEmpty,
|
||||
@@ -2523,7 +2553,15 @@ extension BLEService {
|
||||
let payload: Data
|
||||
do {
|
||||
let now = Date()
|
||||
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
let sealed: Data
|
||||
let prekeyID: UInt32?
|
||||
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
||||
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
||||
prekeyID = prekey.id
|
||||
} else {
|
||||
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
prekeyID = nil
|
||||
}
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientNoiseKey,
|
||||
@@ -2531,7 +2569,8 @@ extension BLEService {
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||
ciphertext: sealed,
|
||||
copies: TransportConfig.courierInitialCopies
|
||||
copies: TransportConfig.courierInitialCopies,
|
||||
prekeyID: prekeyID
|
||||
)
|
||||
guard let encoded = envelope.encode() else { return false }
|
||||
payload = encoded
|
||||
@@ -2550,6 +2589,22 @@ extension BLEService {
|
||||
return true
|
||||
}
|
||||
|
||||
/// The prekey to seal a courier message with, or nil to fall back to
|
||||
/// static sealing. The real signal is a verified, unexpired bundle with a
|
||||
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
|
||||
/// for peers we currently see on the mesh (a cached bundle can outlive a
|
||||
/// peer's downgrade to a build that no longer holds the privates).
|
||||
/// Re-deposits of the same message reuse its assigned prekey, so one
|
||||
/// message consumes exactly one prekey ID regardless of courier count.
|
||||
private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
|
||||
let shortID = PeerID(publicKey: recipientNoiseKey)
|
||||
let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
|
||||
if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) {
|
||||
return nil
|
||||
}
|
||||
return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey)
|
||||
}
|
||||
|
||||
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
@@ -2585,7 +2640,25 @@ extension BLEService {
|
||||
|
||||
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||
do {
|
||||
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||
let typedPayload: Data
|
||||
let senderStaticKey: Data
|
||||
if let prekeyID = envelope.prekeyID {
|
||||
// Envelope v2: sealed to one of our one-time prekeys. Opening
|
||||
// consumes the prekey (48h redelivery grace), which shrinks our
|
||||
// published bundle under a strictly newer generatedAt. Re-gossip
|
||||
// so peers replace their cached copy and stop assigning the
|
||||
// consumed ID before the grace lapses; force the broadcast when
|
||||
// the batch also topped back up (low-water), otherwise let the
|
||||
// rebroadcast throttle coalesce bursts.
|
||||
let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
|
||||
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
|
||||
if opened.consumedPrekey {
|
||||
let replenished = noiseService.replenishPrekeysIfNeeded()
|
||||
sendPrekeyBundle(force: replenished)
|
||||
}
|
||||
} else {
|
||||
(typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||
}
|
||||
guard let typeRaw = typedPayload.first,
|
||||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||
payloadType == .privateMessage else {
|
||||
@@ -2716,6 +2789,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: Link capability snapshots (thread-safe via bleQueue)
|
||||
|
||||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||||
@@ -3210,6 +3432,9 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .prekeyBundle:
|
||||
handlePrekeyBundle(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
@@ -3273,6 +3498,12 @@ extension BLEService {
|
||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let result = announceHandler.handle(packet, from: peerID)
|
||||
|
||||
// A verified announce is the moment a signing key becomes bound to this
|
||||
// owner's noise key: retry any prekey bundle that raced ahead of it.
|
||||
if let result, result.isVerified {
|
||||
drainPendingPrekeyBundles(for: result.peerID)
|
||||
}
|
||||
|
||||
// Courier work: an announce is the moment we learn a peer's Noise
|
||||
// static key, so check whether we're carrying mail addressed to them
|
||||
// (or spray-able mail they could carry). Verified announces only.
|
||||
|
||||
@@ -42,9 +42,11 @@ final class CourierStore {
|
||||
var sprayedTo: Set<Data>
|
||||
/// Last speculative multi-hop handover toward a relayed announce.
|
||||
var lastRemoteHandoverAt: Date?
|
||||
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
||||
let prekeyID: UInt32?
|
||||
|
||||
var envelope: CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -56,7 +58,8 @@ final class CourierStore {
|
||||
tier: CourierDepositTier,
|
||||
copies: UInt8,
|
||||
sprayedTo: Set<Data> = [],
|
||||
lastRemoteHandoverAt: Date? = nil
|
||||
lastRemoteHandoverAt: Date? = nil,
|
||||
prekeyID: UInt32? = nil
|
||||
) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
@@ -67,6 +70,7 @@ final class CourierStore {
|
||||
self.copies = copies
|
||||
self.sprayedTo = sprayedTo
|
||||
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||
self.prekeyID = prekeyID
|
||||
}
|
||||
|
||||
// Files written before tiers/spray lack the newer fields; treat that
|
||||
@@ -82,6 +86,7 @@ final class CourierStore {
|
||||
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
||||
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +191,8 @@ final class CourierStore {
|
||||
depositorNoiseKey: depositorNoiseKey,
|
||||
storedAt: date,
|
||||
tier: tier,
|
||||
copies: envelope.copies
|
||||
copies: envelope.copies,
|
||||
prekeyID: envelope.prekeyID
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
|
||||
@@ -172,6 +172,10 @@ final class NoiseEncryptionService {
|
||||
// Security components
|
||||
private let rateLimiter = NoiseRateLimiter()
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
// One-time prekeys for forward-secret courier sealing (lazy generation
|
||||
// inside the store; the batch is minted on first bundle build).
|
||||
private let localPrekeys: LocalPrekeyStore
|
||||
|
||||
// Session maintenance
|
||||
private var rekeyTimer: Timer?
|
||||
@@ -200,6 +204,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
|
||||
// BCH-01-009: Load or create static identity key with proper error handling
|
||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||
@@ -412,13 +417,111 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - One-Time Prekey Envelopes (forward-secret Noise X)
|
||||
|
||||
/// Domain separation for prekey-sealed envelopes: distinct from both the
|
||||
/// interactive XX transcripts and static-sealed courier envelopes, and
|
||||
/// bound to the specific prekey ID so a ciphertext cannot be replayed
|
||||
/// against a different prekey.
|
||||
private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8)
|
||||
|
||||
private static func prekeyPrologue(for prekeyID: UInt32) -> Data {
|
||||
var prologue = prekeyProloguePrefix
|
||||
var big = prekeyID.bigEndian
|
||||
withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) }
|
||||
return prologue
|
||||
}
|
||||
|
||||
/// Encrypt a payload to one of the recipient's gossiped one-time prekeys
|
||||
/// (Noise X where the responder static is the prekey, not the identity
|
||||
/// key). Unlike `sealCourierPayload`, this is forward secret: once the
|
||||
/// recipient consumes the prekey and its grace window lapses, the private
|
||||
/// key is deleted and captured ciphertext becomes undecryptable even if
|
||||
/// the recipient's identity key is later compromised. The initiator's
|
||||
/// static still rides inside (encrypted), so the recipient authenticates
|
||||
/// the sender exactly as with static-sealed envelopes.
|
||||
func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data {
|
||||
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey)
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
remoteStaticKey: remoteKey,
|
||||
prologue: Self.prekeyPrologue(for: recipientPrekey.id)
|
||||
)
|
||||
return try handshake.writeMessage(payload: payload)
|
||||
}
|
||||
|
||||
/// Decrypt an envelope sealed to one of our one-time prekeys. On success
|
||||
/// the prekey is marked consumed (its private key survives a 48h grace
|
||||
/// window for spray-and-wait redeliveries, then is deleted for good).
|
||||
/// Returns the payload, the sender's authenticated static key (same
|
||||
/// contract as `openCourierPayload`), and whether this open actually
|
||||
/// retired the prekey — false for a redelivery of already-consumed mail —
|
||||
/// so the caller can re-gossip the shrunken bundle only when it changed.
|
||||
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
|
||||
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
|
||||
throw NoiseEncryptionError.unknownPrekey
|
||||
}
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: prekeyPrivate,
|
||||
prologue: Self.prekeyPrologue(for: prekeyID)
|
||||
)
|
||||
let payload = try handshake.readMessage(envelopeCiphertext)
|
||||
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let consumedPrekey = localPrekeys.markConsumed(prekeyID)
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey)
|
||||
}
|
||||
|
||||
/// Current signed prekey bundle for gossip, minting the initial batch on
|
||||
/// first use. Nil only when signing fails.
|
||||
func currentPrekeyBundle() -> PrekeyBundle? {
|
||||
let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys()
|
||||
guard !prekeys.isEmpty else { return nil }
|
||||
let unsigned = PrekeyBundle(
|
||||
noiseStaticPublicKey: getStaticPublicKeyData(),
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: Data(count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
guard let signature = signData(unsigned.signableBytes()) else { return nil }
|
||||
return PrekeyBundle(
|
||||
noiseStaticPublicKey: unsigned.noiseStaticPublicKey,
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
/// Verify a peer's bundle signature against their announce-bound Ed25519
|
||||
/// signing key.
|
||||
func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool {
|
||||
verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey)
|
||||
}
|
||||
|
||||
/// Prune dead prekeys and top the batch back up when consumption runs it
|
||||
/// low. Returns true when the published bundle changed and should be
|
||||
/// re-gossiped.
|
||||
@discardableResult
|
||||
func replenishPrekeysIfNeeded() -> Bool {
|
||||
localPrekeys.replenishIfNeeded()
|
||||
}
|
||||
|
||||
/// Clear persistent identity (for panic mode)
|
||||
func clearPersistentIdentity() {
|
||||
// Clear from keychain
|
||||
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
||||
// One-time prekey privates go with the identity they were bound to.
|
||||
localPrekeys.wipe()
|
||||
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
||||
// Stop rekey timer
|
||||
stopRekeyTimer()
|
||||
@@ -812,4 +915,7 @@ struct NoiseMessage: Codable {
|
||||
enum NoiseEncryptionError: Error {
|
||||
case handshakeRequired
|
||||
case sessionNotEstablished
|
||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||
/// deleted after its grace window, or wiped in a panic).
|
||||
case unknownPrekey
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// LocalPrekeyStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Owns this device's one-time Curve25519 prekey private keys.
|
||||
///
|
||||
/// Privates persist in the Keychain (single blob, same protection class as
|
||||
/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the
|
||||
/// gossiped bundle; when consumption drops the unconsumed count below
|
||||
/// `replenishThreshold`, the batch tops back up and the bundle's
|
||||
/// `generatedAt` bumps so peers replace their cached copy.
|
||||
///
|
||||
/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext
|
||||
/// (or a re-seal of the same message to the same prekey ID) can arrive via
|
||||
/// several couriers days apart. A consumed prekey's private key is therefore
|
||||
/// retained for `consumedGraceSeconds` after first use and only then deleted.
|
||||
/// Tradeoff: during the grace window a compromise of the device still exposes
|
||||
/// mail sealed to that prekey — the forward-secrecy clock starts at deletion,
|
||||
/// not at first open. Refusing new ciphertexts while accepting redeliveries
|
||||
/// is not possible (the recipient cannot distinguish them), so the window is
|
||||
/// kept short and fixed.
|
||||
final class LocalPrekeyStore {
|
||||
struct Record: Codable {
|
||||
let id: UInt32
|
||||
let privateKey: Data
|
||||
let createdAt: Date
|
||||
var consumedAt: Date?
|
||||
}
|
||||
|
||||
private struct Persisted: Codable {
|
||||
var records: [Record]
|
||||
var nextID: UInt32
|
||||
var generatedAt: UInt64
|
||||
}
|
||||
|
||||
enum Policy {
|
||||
static let batchSize = PrekeyBundle.maxPrekeys
|
||||
static let replenishThreshold = 3
|
||||
/// How long a consumed prekey private survives for duplicate courier
|
||||
/// deliveries of mail sealed to it.
|
||||
static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60
|
||||
/// Unconsumed prekeys older than this are rotated out: no honest
|
||||
/// sender seals to a bundle that stale (see
|
||||
/// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`).
|
||||
static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60
|
||||
}
|
||||
|
||||
private static let keychainKey = "prekeysV1"
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let now: () -> Date
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local")
|
||||
|
||||
// Guarded by `queue`.
|
||||
private var records: [Record] = []
|
||||
private var nextID: UInt32 = 0
|
||||
private var generatedAt: UInt64 = 0
|
||||
private var loaded = false
|
||||
|
||||
init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) {
|
||||
self.keychain = keychain
|
||||
self.now = now
|
||||
}
|
||||
|
||||
// MARK: - Bundle contents (public prekeys)
|
||||
|
||||
/// Unconsumed public prekeys for the gossiped bundle, generating the
|
||||
/// initial batch on first use. Sorted by ID for canonical signing bytes.
|
||||
func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
_ = replenishLocked()
|
||||
let prekeys = records
|
||||
.filter { $0.consumedAt == nil }
|
||||
.sorted { $0.id < $1.id }
|
||||
.compactMap { record -> PrekeyBundle.Prekey? in
|
||||
guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil }
|
||||
return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation)
|
||||
}
|
||||
return (prekeys, generatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Opening (private prekeys)
|
||||
|
||||
/// Private key for a prekey ID: unconsumed, or consumed within the
|
||||
/// redelivery grace window.
|
||||
func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
let date = now()
|
||||
guard let record = records.first(where: { $0.id == id }) else { return nil }
|
||||
if let consumedAt = record.consumedAt,
|
||||
date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds {
|
||||
return nil
|
||||
}
|
||||
return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a prekey consumed (starts its grace clock). Idempotent: a
|
||||
/// redelivery within the grace window does not restart the clock.
|
||||
///
|
||||
/// Returns true when this call actually retired a prekey, i.e. the
|
||||
/// published bundle shrank. Consuming a prekey drops it from
|
||||
/// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too:
|
||||
/// otherwise peers that cached the old bundle reject the same-`generatedAt`
|
||||
/// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed
|
||||
/// ID, and their mail starts failing `unknownPrekey` once the 48h grace
|
||||
/// lapses. The caller re-gossips on a true result.
|
||||
@discardableResult
|
||||
func markConsumed(_ id: UInt32) -> Bool {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
guard let index = records.firstIndex(where: { $0.id == id }),
|
||||
records[index].consumedAt == nil else { return false }
|
||||
records[index].consumedAt = now()
|
||||
advanceGeneratedAtLocked()
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Prunes dead prekeys and tops the unconsumed batch back up when it runs
|
||||
/// low. Returns true when the published bundle changed (caller should
|
||||
/// re-gossip).
|
||||
@discardableResult
|
||||
func replenishIfNeeded() -> Bool {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
return replenishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
var unconsumedCount: Int {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
return records.filter { $0.consumedAt == nil }.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all prekey privates from memory and the Keychain.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
records.removeAll()
|
||||
nextID = 0
|
||||
generatedAt = 0
|
||||
loaded = true
|
||||
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func replenishLocked() -> Bool {
|
||||
let date = now()
|
||||
|
||||
// Consumed prekeys past the grace window are gone for good; stale
|
||||
// unconsumed ones rotate out (their bundle is too old to seal to).
|
||||
let recordsBefore = records.count
|
||||
let unconsumedBefore = records.filter { $0.consumedAt == nil }.count
|
||||
records.removeAll { record in
|
||||
if let consumedAt = record.consumedAt {
|
||||
return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds
|
||||
}
|
||||
return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds
|
||||
}
|
||||
// Only a change to the *unconsumed* set alters the published bundle;
|
||||
// grace-expired consumed keys were never in it.
|
||||
let unconsumed = records.filter { $0.consumedAt == nil }.count
|
||||
var bundleChanged = unconsumed != unconsumedBefore
|
||||
|
||||
if unconsumed < Policy.replenishThreshold {
|
||||
for _ in unconsumed..<Policy.batchSize {
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
records.append(Record(id: nextID, privateKey: key.rawRepresentation, createdAt: date, consumedAt: nil))
|
||||
nextID &+= 1
|
||||
}
|
||||
advanceGeneratedAtLocked()
|
||||
bundleChanged = true
|
||||
SecureLogger.debug("🔑 Replenished one-time prekeys (unconsumed was \(unconsumed))", category: .security)
|
||||
}
|
||||
|
||||
if bundleChanged || records.count != recordsBefore { persistLocked() }
|
||||
return bundleChanged
|
||||
}
|
||||
|
||||
/// Advance `generatedAt` strictly monotonically. Uses wall-clock millis but
|
||||
/// never repeats or regresses, so two changes within the same millisecond
|
||||
/// still produce distinct, increasing stamps that peers' monotonic ingest
|
||||
/// accepts.
|
||||
private func advanceGeneratedAtLocked() {
|
||||
let nowMillis = UInt64(max(0, now().timeIntervalSince1970 * 1000))
|
||||
generatedAt = max(nowMillis, generatedAt &+ 1)
|
||||
}
|
||||
|
||||
private func loadLocked() {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
guard let data = keychain.getIdentityKey(forKey: Self.keychainKey),
|
||||
let persisted = try? JSONDecoder().decode(Persisted.self, from: data) else {
|
||||
return
|
||||
}
|
||||
records = persisted.records
|
||||
nextID = persisted.nextID
|
||||
generatedAt = persisted.generatedAt
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
let persisted = Persisted(records: records, nextID: nextID, generatedAt: generatedAt)
|
||||
guard let data = try? JSONEncoder().encode(persisted) else {
|
||||
SecureLogger.error("Failed to encode prekey store", category: .keychain)
|
||||
return
|
||||
}
|
||||
if !keychain.saveIdentityKey(data, forKey: Self.keychainKey) {
|
||||
SecureLogger.error("Failed to persist prekey store", category: .keychain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// PrekeyBundleStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Signature-verified one-time prekey bundles received from other peers.
|
||||
///
|
||||
/// One bundle per Noise static key: a newer `generatedAt` replaces the cached
|
||||
/// copy, keeping the IDs we already sealed with marked used so a prekey is
|
||||
/// never reused across messages. Assignments are remembered per message ID so
|
||||
/// deposit retries of the same message re-use its prekey (and its budget)
|
||||
/// instead of burning a fresh one per courier.
|
||||
///
|
||||
/// Only public key material lives here; it persists to disk so a sender can
|
||||
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
||||
final class PrekeyBundleStore {
|
||||
struct StoredBundle: Codable {
|
||||
let noiseKey: Data
|
||||
var generatedAt: UInt64
|
||||
var prekeyIDs: [UInt32]
|
||||
var prekeyPublicKeys: [Data]
|
||||
/// IDs this device already sealed with (never reused).
|
||||
var usedIDs: Set<UInt32>
|
||||
/// messageID → prekey ID, so re-deposits of one message share one prekey.
|
||||
var assignments: [String: UInt32]
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
enum Limits {
|
||||
static let maxPeers = 200
|
||||
/// Don't seal to bundles older than this: the owner may have rotated
|
||||
/// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
|
||||
static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
|
||||
}
|
||||
|
||||
static let shared = PrekeyBundleStore()
|
||||
|
||||
private var bundles: [Data: StoredBundle] = [:]
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
|
||||
private let fileURL: URL?
|
||||
private let maxPeers: Int
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(
|
||||
persistsToDisk: Bool = true,
|
||||
fileURL: URL? = nil,
|
||||
maxPeers: Int = Limits.maxPeers,
|
||||
now: @escaping () -> Date = Date.init
|
||||
) {
|
||||
self.now = now
|
||||
self.maxPeers = maxPeers
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Ingest
|
||||
|
||||
/// Stores a bundle whose signature the caller has already verified
|
||||
/// against the owner's announce-bound signing key. Returns false when an
|
||||
/// equal-or-newer bundle is already cached (nothing changed).
|
||||
@discardableResult
|
||||
func ingest(_ bundle: PrekeyBundle) -> Bool {
|
||||
guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
|
||||
!bundle.prekeys.isEmpty else { return false }
|
||||
return queue.sync {
|
||||
if let existing = bundles[bundle.noiseStaticPublicKey],
|
||||
existing.generatedAt >= bundle.generatedAt {
|
||||
return false
|
||||
}
|
||||
let previous = bundles[bundle.noiseStaticPublicKey]
|
||||
let newIDs = Set(bundle.prekeys.map(\.id))
|
||||
// Keep consumption state for IDs the fresh bundle still offers
|
||||
// (a top-up keeps the owner's unconsumed keys); drop the rest.
|
||||
let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
|
||||
let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
|
||||
bundles[bundle.noiseStaticPublicKey] = StoredBundle(
|
||||
noiseKey: bundle.noiseStaticPublicKey,
|
||||
generatedAt: bundle.generatedAt,
|
||||
prekeyIDs: bundle.prekeys.map(\.id),
|
||||
prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
|
||||
usedIDs: carriedUsed,
|
||||
assignments: carriedAssignments,
|
||||
updatedAt: now()
|
||||
)
|
||||
enforceCapLocked()
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sealing support
|
||||
|
||||
/// Whether an unexpired bundle with sealable prekeys is cached for a peer.
|
||||
func hasUsableBundle(for noiseKey: Data) -> Bool {
|
||||
queue.sync {
|
||||
guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
|
||||
return bundle.usedIDs.count < bundle.prekeyIDs.count
|
||||
}
|
||||
}
|
||||
|
||||
/// The prekey to seal a message with: the message's existing assignment if
|
||||
/// any (re-deposits reuse it), else the lowest unused ID, which is then
|
||||
/// marked used. Nil when no fresh bundle is cached or all its prekeys are
|
||||
/// spent — callers fall back to static sealing.
|
||||
func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
|
||||
queue.sync {
|
||||
guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
|
||||
|
||||
if let assigned = bundle.assignments[messageID],
|
||||
let index = bundle.prekeyIDs.firstIndex(of: assigned) {
|
||||
return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
|
||||
}
|
||||
|
||||
guard let index = bundle.prekeyIDs.indices
|
||||
.filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
|
||||
.min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
|
||||
return nil
|
||||
}
|
||||
let id = bundle.prekeyIDs[index]
|
||||
bundle.usedIDs.insert(id)
|
||||
bundle.assignments[messageID] = id
|
||||
bundle.updatedAt = now()
|
||||
bundles[recipientNoiseKey] = bundle
|
||||
persistLocked()
|
||||
return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
/// Panic wipe: drop all cached bundles from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
bundles.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
|
||||
let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
|
||||
return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
|
||||
}
|
||||
|
||||
private func enforceCapLocked() {
|
||||
while bundles.count > maxPeers {
|
||||
guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
|
||||
bundles.removeValue(forKey: victim.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if bundles.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(Array(bundles.values))
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL else { return }
|
||||
queue.sync {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
|
||||
return
|
||||
}
|
||||
for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
|
||||
bundles[bundle.noiseKey] = bundle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("prekeys", isDirectory: true)
|
||||
.appendingPathComponent("bundles.json")
|
||||
}
|
||||
}
|
||||
@@ -289,4 +289,16 @@ enum TransportConfig {
|
||||
// Cooldown between speculative multi-hop handovers of the same envelope
|
||||
// toward a recipient heard only via relayed announces.
|
||||
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
|
||||
|
||||
// One-time prekey bundles (forward-secret courier sealing)
|
||||
// Own gossip-sync round for bundles: modest cadence, bounded peer count,
|
||||
// and a long freshness window so bundles persist mesh-wide while their
|
||||
// owners are away.
|
||||
static let syncPrekeyBundleCapacity: Int = 200
|
||||
static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
|
||||
// Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on
|
||||
// announces, keep it alive in peers' gossip stores; changed bundles are
|
||||
// sent immediately.
|
||||
static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ final class GossipSyncManager {
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
var responseRateLimitMaxResponses: Int = 8
|
||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
// Prekey bundles: one per peer, own sync round, long freshness so
|
||||
// bundles persist mesh-wide while their owners are offline.
|
||||
var prekeyBundleCapacity: Int = 200
|
||||
var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
|
||||
}
|
||||
|
||||
private let myPeerID: PeerID
|
||||
@@ -91,6 +96,10 @@ final class GossipSyncManager {
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
||||
// Latest verified prekey bundle per owner. Unlike announces, bundles are
|
||||
// NOT dropped on leave/stale peer: their whole purpose is reaching a
|
||||
// sender while the owner is away.
|
||||
private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
private var archiveDirty = false
|
||||
|
||||
// Timer
|
||||
@@ -119,6 +128,9 @@ final class GossipSyncManager {
|
||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
|
||||
if archive != nil {
|
||||
@@ -155,6 +167,9 @@ final class GossipSyncManager {
|
||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.fileTransfer)
|
||||
}
|
||||
if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.prekeyBundle)
|
||||
}
|
||||
self.sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
}
|
||||
@@ -169,9 +184,15 @@ final class GossipSyncManager {
|
||||
// messages get the long town-crier window; fragments, file transfers and
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let maxAgeSeconds = packet.type == MessageType.message.rawValue
|
||||
? config.publicMessageMaxAgeSeconds
|
||||
: config.maxMessageAgeSeconds
|
||||
let maxAgeSeconds: TimeInterval
|
||||
switch packet.type {
|
||||
case MessageType.message.rawValue:
|
||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||
case MessageType.prekeyBundle.rawValue:
|
||||
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
|
||||
default:
|
||||
maxAgeSeconds = config.maxMessageAgeSeconds
|
||||
}
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||
|
||||
@@ -224,6 +245,29 @@ final class GossipSyncManager {
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||
case .prekeyBundle:
|
||||
// Callers only feed verified bundles here (own bundles at send
|
||||
// time, peers' after signature verification), so gossip never
|
||||
// spreads a bundle this node couldn't attribute.
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
// Key by the bundle's authenticated identity (its noise static key),
|
||||
// NOT the unauthenticated packet senderID. Otherwise one valid
|
||||
// bundle re-broadcast under many fabricated sender IDs would create
|
||||
// one cache entry each and exhaust the per-owner cap, starving
|
||||
// legitimate bundles. One owner ⇒ at most one entry.
|
||||
guard let bundle = PrekeyBundle.decode(packet.payload) else { return }
|
||||
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
|
||||
if let existing = latestPrekeyBundleByPeer[owner],
|
||||
existing.packet.timestamp >= packet.timestamp {
|
||||
return
|
||||
}
|
||||
// Bounded owner count; replacing a known owner's bundle is always
|
||||
// allowed so the cap can't block refreshes.
|
||||
guard latestPrekeyBundleByPeer[owner] != nil
|
||||
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -364,6 +408,24 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
@@ -383,6 +445,11 @@ final class GossipSyncManager {
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
@@ -399,6 +466,8 @@ final class GossipSyncManager {
|
||||
cap = max(1, config.fragmentCapacity)
|
||||
} else if types == .fileTransfer {
|
||||
cap = max(1, config.fileTransferCapacity)
|
||||
} else if types == .prekeyBundle {
|
||||
cap = max(1, config.prekeyBundleCapacity)
|
||||
} else {
|
||||
cap = max(1, config.seenCapacity)
|
||||
}
|
||||
@@ -440,6 +509,9 @@ final class GossipSyncManager {
|
||||
}
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Archive (public message persistence)
|
||||
@@ -527,6 +599,8 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func removeState(for peerID: PeerID) {
|
||||
// Deliberately keeps the peer's prekey bundle: bundles exist to reach
|
||||
// owners who left the mesh, and they age out on their own schedule.
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||
let messageCountBefore = messages.packets.count
|
||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
@@ -552,6 +626,12 @@ extension GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
func _hasPrekeyBundle(for peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
latestPrekeyBundleByPeer[peerID] != nil
|
||||
}
|
||||
}
|
||||
|
||||
func _messageCount(for peerID: PeerID) -> Int {
|
||||
queue.sync {
|
||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||
|
||||
@@ -39,6 +39,11 @@ struct SyncTypeFlags: OptionSet {
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
// Bit 8 is reserved for the board feature. The bitfield is already 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +57,8 @@ struct SyncTypeFlags: OptionSet {
|
||||
case 5: return .fragment
|
||||
case 6: return .requestSync
|
||||
case 7: return .fileTransfer
|
||||
// Bit 8 reserved (board).
|
||||
case 9: return .prekeyBundle
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -61,6 +68,7 @@ struct SyncTypeFlags: OptionSet {
|
||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
||||
|
||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||
|
||||
|
||||
@@ -290,6 +290,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
func clearCurrentPublicTimeline() {
|
||||
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
|
||||
|
||||
// The SPM test process shares the real Application Support tree, so this
|
||||
// detached deletion can land mid-test under parallel scheduling and flake
|
||||
// a file-dependent test. Tests never need the on-disk media cleared.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
|
||||
Task.detached(priority: .utility) {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
|
||||
@@ -1205,6 +1205,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
GossipMessageArchive.wipeDefault()
|
||||
StoreAndForwardMetrics.shared.reset()
|
||||
|
||||
// Drop cached peers' prekey bundles (who we could write to is
|
||||
// metadata too). Our own prekey privates are keychain-backed and go
|
||||
// with deleteAllKeychainData above plus the identity reset below.
|
||||
PrekeyBundleStore.shared.wipe()
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
@@ -1273,6 +1278,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
// Delete ALL media files (incoming and outgoing) in background
|
||||
Task.detached(priority: .utility) {
|
||||
// The SPM test process shares the real Application Support tree, so
|
||||
// this detached tree-delete can land mid-test under parallel
|
||||
// scheduling and flake a file-dependent test; tests never need the
|
||||
// on-disk media wiped.
|
||||
guard !TestEnvironment.isRunningTests else { return }
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
|
||||
@@ -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.fragmentSyncIntervalSeconds = 1
|
||||
config.fileTransferSyncIntervalSeconds = 1
|
||||
config.prekeyBundleSyncIntervalSeconds = 1
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
@@ -195,19 +196,22 @@ struct GossipSyncManagerTests {
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
// 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
|
||||
#expect(sentPackets.count == 3)
|
||||
#expect(sentPackets.count == 4)
|
||||
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) }
|
||||
#expect(allTypes.contains(.announce))
|
||||
#expect(allTypes.contains(.message))
|
||||
#expect(allTypes.contains(.fragment))
|
||||
#expect(allTypes.contains(.fileTransfer))
|
||||
#expect(allTypes.contains(.prekeyBundle))
|
||||
#expect(decoded.contains { $0.types == .publicMessages })
|
||||
#expect(decoded.contains { $0.types == .fragment })
|
||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||
#expect(decoded.contains { $0.types == .prekeyBundle })
|
||||
}
|
||||
|
||||
@Test func truncatedFilterCarriesSinceCursor() throws {
|
||||
@@ -292,6 +296,7 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.prekeyBundleSyncIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
@@ -401,6 +406,7 @@ struct GossipSyncManagerTests {
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.prekeyBundleSyncIntervalSeconds = 0
|
||||
config.responseRateLimitMaxResponses = 1
|
||||
config.responseRateLimitWindowSeconds = 60
|
||||
|
||||
@@ -455,6 +461,7 @@ struct GossipSyncManagerTests {
|
||||
#expect(types.contains(.message))
|
||||
#expect(types.contains(.fragment))
|
||||
#expect(types.contains(.fileTransfer))
|
||||
#expect(types.contains(.prekeyBundle))
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncHonorsTypeFilter() async throws {
|
||||
@@ -507,6 +514,93 @@ struct GossipSyncManagerTests {
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@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()))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ public struct CourierEnvelope: Equatable {
|
||||
/// the holder may still hand to other couriers (binary split on each
|
||||
/// spray). 1 means carry-only — deliver to the recipient, never re-spray.
|
||||
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
|
||||
/// Couriered messages are text-sized; media transfers are out of scope.
|
||||
@@ -43,18 +50,20 @@ public struct CourierEnvelope: Equatable {
|
||||
case expiry = 0x02
|
||||
case ciphertext = 0x03
|
||||
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.expiry = expiry
|
||||
self.ciphertext = ciphertext
|
||||
self.copies = min(max(copies, 1), Self.maxCopies)
|
||||
self.prekeyID = prekeyID
|
||||
}
|
||||
|
||||
/// The same envelope with a different remaining copy budget.
|
||||
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 {
|
||||
@@ -97,6 +106,14 @@ public struct CourierEnvelope: Equatable {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -108,6 +125,7 @@ public struct CourierEnvelope: Equatable {
|
||||
var expiry: UInt64?
|
||||
var ciphertext: Data?
|
||||
var copies: UInt8 = 1
|
||||
var prekeyID: UInt32?
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
@@ -133,6 +151,9 @@ public struct CourierEnvelope: Equatable {
|
||||
case .copies:
|
||||
guard length == 1 else { return nil }
|
||||
copies = value.first ?? 1
|
||||
case .prekeyID:
|
||||
guard length == 4 else { return nil }
|
||||
prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
case nil:
|
||||
// Unknown TLV: skip for forward compatibility.
|
||||
continue
|
||||
@@ -140,7 +161,7 @@ public struct CourierEnvelope: Equatable {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -16,14 +16,17 @@ public enum MessageType: UInt8 {
|
||||
case leave = 0x03 // "I'm leaving"
|
||||
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
|
||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||
|
||||
|
||||
// Noise encryption
|
||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
|
||||
// 0x23, 0x25-0x28 reserved for in-flight protocol work.
|
||||
case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
@@ -36,6 +39,7 @@ public enum MessageType: UInt8 {
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .prekeyBundle: return "prekeyBundle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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