Compare commits

...
10 Commits
Author SHA1 Message Date
jackandClaude Fable 5 02828731b9 Drop couriered mail from blocked senders at envelope open
The UI-layer block check (isPeerBlocked in the transport event
coordinator) resolves a fingerprint from the live session or peer list,
but a couriered message arrives precisely when its sender is absent —
no session, no registry entry — so the check failed open and a blocked
identity's mail was delivered anyway. Gate in openCourierEnvelope,
where the sealed sender's full static key is in hand.

End-to-end test ferries a full deposit→carry→handover round and
verifies the envelope from a blocked sender never reaches the delegate
(confirmed failing without the gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:34:46 +02:00
jackandClaude Fable 5 f5b2150f11 Merge branch 'main' into feat/courier
Resolve DeliveryStatusView conflict: adopt main's bitchatDescription
extension (tooltip + VoiceOver via body modifiers) and fold the
.carried case into it, dropping the branch's Strings enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:27:17 +02:00
jackandGitHub b28fb26504 Merge branch 'main' into feat/courier 2026-07-01 23:52:52 +02:00
jackandGitHub ba0861a446 Merge branch 'main' into feat/courier 2026-07-01 23:02:13 +02:00
jackandClaude Fable 5 424c47be82 Use Xcode-bundled Swift in CI instead of a standalone toolchain
The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:54:03 +02:00
jackandClaude Fable 5 20c1c0f687 Gate courier handover on direct announces and isolate store test
Envelopes are removed from the courier store optimistically, so releasing
them on a relayed (multi-hop) announce risks losing carried mail to a
speculative flood that never reaches the recipient. Handover now also
requires the announce to have arrived directly (full TTL), i.e. an actual
encounter with a live link; regression test builds a relayed copy of a
genuinely signed announce (TTL is excluded from announce signatures).

Also make CourierStore's on-disk location injectable so the persistence
test round-trips through a temp directory instead of wiping the real
Application Support store, and reattach BLEAnnounceHandler's doc comment
to the class it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:48:10 +02:00
jack ebdcdf83dd Authenticate courier deposits by ingress peer 2026-06-19 17:39:00 +02:00
jack 2c04f65c83 Fix courier handoff verification and directed sends 2026-06-17 10:04:16 +02:00
jackandGitHub 293380e671 Merge branch 'main' into feat/courier 2026-06-17 10:03:07 +02:00
jackandClaude Fable 5 5aaa209020 Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers
When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:13:16 +02:00
27 changed files with 1955 additions and 36 deletions
+7 -1
View File
@@ -93,6 +93,7 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key
case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
}
enum NoiseRole {
@@ -601,7 +602,7 @@ final class NoiseHandshakeState {
switch pattern {
case .XX:
break // No pre-message keys
case .IK, .NK:
case .IK, .NK, .X:
if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
@@ -904,6 +905,7 @@ extension NoisePattern {
case .XX: return "XX"
case .IK: return "IK"
case .NK: return "NK"
case .X: return "X"
}
}
@@ -925,6 +927,10 @@ extension NoisePattern {
[.e, .es], // -> e, es
[.e, .ee] // <- e, ee
]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
}
}
}
+22 -5
View File
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
let scheduleAfterglow: (TimeInterval) -> Void
}
/// Outcome of an accepted announce, surfaced so the service can run
/// follow-up work (e.g. courier handover) that keys off the announce.
struct BLEAnnounceHandlingResult {
let peerID: PeerID
let announcement: AnnouncementPacket
let isDirectAnnounce: Bool
let isVerified: Bool
}
/// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response.
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
let env = environment
let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate(
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
announcement = acceptance.announcement
case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return
return nil
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
return nil
case .reject(.selfAnnounce):
return
return nil
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
return nil
}
// Suppress announce logs to reduce noise
@@ -210,5 +220,12 @@ final class BLEAnnounceHandler {
let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay)
}
return BLEAnnounceHandlingResult(
peerID: peerID,
announcement: announcement,
isDirectAnnounce: isDirectAnnounce,
isVerified: verifiedAnnounce
)
}
}
+64 -6
View File
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
let rawAllowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
)
if let directedPeerHint,
let directedSelection = directLinks(
to: directedPeerHint,
links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return directedSelection
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
let allowed = collapseDuplicateLinksPerPeer(
allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs)
}
private static func directLinks(
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
return nil
}
return BLEFanoutSelection(
peripheralIDs: Set(directLinks.peripheralIDs),
centralIDs: Set(directLinks.centralIDs)
)
}
private static func hasBoundLink(
to peerID: PeerID,
peripheralIDs: [String],
centralIDs: [String],
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> Bool {
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
}
// Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
return false
}
}
+173 -3
View File
@@ -46,6 +46,20 @@ final class BLEService: NSObject {
// 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline
// third parties, and the trust gate for accepting deposits. Injectable
// for tests; main-actor policy because favorites live on the main actor.
var courierStore: CourierStore = .shared
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
}
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances.
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -888,6 +902,10 @@ final class BLEService: NSObject {
} else {
packetToSend = packet
}
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
@@ -1047,6 +1065,9 @@ final class BLEService: NSObject {
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
}
@@ -2468,7 +2489,142 @@ extension BLEService {
ttl: messageTTL
)
}
// 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.
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
let connected = couriers.filter { isPeerConnected($0) }
guard !connected.isEmpty,
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
return false
}
let payload: Data
do {
let now = Date()
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientNoiseKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
} catch {
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
return false
}
messageQueue.async { [weak self] in
guard let self else { return }
for courier in connected {
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
}
}
return true
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: messageTTL
)
}
/// Handles both courier roles for an incoming envelope addressed to us:
/// recipient (the rotating tag matches our static key open and deliver)
/// or courier (a trusted peer is depositing mail for someone else store).
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
// Directed packets only; envelopes addressed elsewhere ride the
// generic relay path untouched.
guard packet.recipientID == myPeerIDData else { return }
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
let myKey = noiseService.getStaticPublicKeyData()
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
openCourierEnvelope(envelope)
} else {
acceptCourierDeposit(envelope, from: peerID)
}
}
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
do {
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
guard let typeRaw = typedPayload.first,
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
return
}
// Couriered mail arrives while the sender is absent, so the UI's
// block check can't resolve their fingerprint from a live session.
// Gate here, where the full static key is in hand.
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
return
}
// Mesh peer IDs are derived from Noise static keys, so the sender
// resolves to the same DM thread whether or not they're present.
let senderPeerID = PeerID(publicKey: senderStaticKey)
let payload = Data(typedPayload.dropFirst())
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))", category: .session)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID,
type: payloadType,
payload: payload,
timestamp: Date()
))
}
} catch {
// Tag collision or stale key: not addressed to us after all.
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
}
}
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return
}
let store = courierStore
let policy = courierDepositPolicy
Task { @MainActor in
guard policy(depositorKey) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
return
}
if store.deposit(envelope, from: depositorKey) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))", category: .session)
}
}
}
/// Hand over any carried envelopes addressed to a peer we just heard from.
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
}
}
// MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -2953,7 +3109,10 @@ extension BLEService {
case .fileTransfer:
handleFileTransfer(packet, from: senderID)
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .leave:
handleLeave(packet, from: senderID)
@@ -3015,7 +3174,18 @@ extension BLEService {
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
announceHandler.handle(packet, from: peerID)
let result = announceHandler.handle(packet, from: peerID)
// Courier handover: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them.
// Direct announces only: envelopes are removed from the store
// optimistically, so handover must ride an established link rather
// than a speculative multi-hop send toward a relayed announce.
guard !courierStore.isEmpty,
let result,
result.isVerified,
result.isDirectAnnounce else { return }
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
}
/// Builds the announce handler environment. All queue hops stay here so
+219
View File
@@ -0,0 +1,219 @@
//
// CourierStore.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 Combine
import Foundation
/// Holds courier envelopes this device is carrying for offline third parties.
///
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
/// never learns sender, recipient, or content. Strict quotas keep the device
/// from becoming a public mailbag: bounded count, bounded per-depositor
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
/// retention policy. Carried mail is included in the panic wipe.
final class CourierStore {
struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data
let expiry: UInt64
let ciphertext: Data
let depositorNoiseKey: Data
let storedAt: Date
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
}
}
enum Limits {
static let maxEnvelopes = 20
static let maxPerDepositor = 5
/// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60
}
static let shared = CourierStore()
/// Number of envelopes currently carried, published on the main thread
/// so the UI can show a "carrying mail" indicator.
@Published private(set) var carriedCount: Int = 0
/// Fast path so hot code (announce handling) can skip tag computation.
var isEmpty: Bool {
queue.sync { envelopes.isEmpty }
}
private var envelopes: [StoredEnvelope] = []
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or
/// validity checks reject it. Trust policy (mutual favorite) is the
/// caller's responsibility; this store only enforces resource bounds.
@discardableResult
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty,
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
!envelope.isExpired(at: date) else {
return false
}
// Reject expiries beyond the policy lifetime so depositors can't pin
// storage longer than the outbox would retain the message itself.
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
return false
}
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently.
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
return true
}
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
return false
}
if envelopes.count >= Limits.maxEnvelopes {
// Oldest-first eviction, matching outbox overflow behavior.
let evicted = envelopes.removeFirst()
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
}
envelopes.append(StoredEnvelope(
recipientTag: envelope.recipientTag,
expiry: envelope.expiry,
ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey,
storedAt: date
))
persistLocked()
return true
}
}
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
}
}
// MARK: - Maintenance
func pruneExpired() {
let date = now()
queue.sync {
pruneExpiredLocked(at: date)
persistLocked()
}
}
/// Panic wipe: drop all carried mail from memory and disk.
func wipe() {
queue.sync {
envelopes.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
publishCountLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
let before = envelopes.count
envelopes.removeAll { $0.envelope.isExpired(at: date) }
if envelopes.count != before {
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
}
}
private func publishCountLocked() {
let count = envelopes.count
DispatchQueue.main.async { [weak self] in
self?.carriedCount = count
}
}
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(envelopes)
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 courier store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
return
}
envelopes = stored
pruneExpiredLocked(at: now())
publishCountLocked()
}
}
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("courier", isDirectory: true)
.appendingPathComponent("envelopes.json")
}
}
+63 -1
View File
@@ -2,11 +2,33 @@ import BitLogger
import BitFoundation
import Foundation
/// Trust and identity lookups the router needs to pick couriers. Backed by
/// the favorites store in production; injectable for tests.
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
/// Whether a peer (by Noise static key) may carry our mail.
var isTrustedCourier: (Data) -> Bool
@MainActor
static func favoritesBacked() -> CourierDirectory {
CourierDirectory(
noiseKey: { peerID in
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
},
isTrustedCourier: { noiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
}
)
}
}
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
private let transports: [Transport]
private let now: () -> Date
private let courierDirectory: CourierDirectory
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -14,6 +36,12 @@ final class MessageRouter {
/// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at
/// least one courier (a connected mutual favorite who will physically
/// carry the sealed envelope). Delivery stays best-effort: the outbox
/// retains the message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
@@ -31,10 +59,17 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never
// get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil
) {
self.transports = transports
self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -94,6 +129,33 @@ final class MessageRouter {
unsent.sendAttempts = 0
enqueue(unsent, for: peerID)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
}
}
/// Last resort when no transport can reach the peer: seal the message to
/// their known static key and hand it to connected mutual favorites who
/// may physically encounter them. The queued copy above stays retained,
/// so direct delivery still wins if the peer reappears first (receivers
/// dedup by message ID).
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
for transport in transports {
let couriers = transport.currentPeerSnapshots()
.filter { snapshot in
guard snapshot.isConnected,
let key = snapshot.noisePublicKey,
key != recipientKey else { return false }
return courierDirectory.isTrustedCourier(key)
}
.prefix(Self.maxCouriersPerMessage)
.map(\.peerID)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
onMessageCarried?(messageID, peerID)
return
}
}
}
@@ -369,6 +369,49 @@ final class NoiseEncryptionService {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
}
// MARK: - Courier Envelopes (one-way Noise X)
/// Domain separation for courier envelopes so X-pattern transcripts can
/// never be confused with interactive XX handshakes.
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
/// Encrypt a payload to a peer's known static key without an interactive
/// handshake (Noise X pattern). Used for store-and-forward envelopes
/// carried by couriers while the recipient is offline.
/// - Warning: One-way messages have no forward secrecy: a later compromise
/// of the recipient's static key exposes envelopes captured in transit.
/// Use established sessions whenever the peer is reachable.
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.courierPrologue
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt a courier envelope addressed to our static key. Returns the
/// payload and the sender's authenticated static public key (the `ss`
/// DH in the X pattern binds the sender's identity to the ciphertext).
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
prologue: Self.courierPrologue
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
/// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent:
case .failed, .partiallyDelivered, .sending, .sent, .carried:
break
}
}
+7
View File
@@ -95,6 +95,12 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -120,6 +126,7 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
+3
View File
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
}
}
@@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1
case .sending: return 2
case .sent: return 3
case .partiallyDelivered: return 4
case .delivered: return 5
case .read: return 6
case .carried: return 4
case .partiallyDelivered: return 5
case .delivered: return 6
case .read: return 7
}
}
}
+3
View File
@@ -1189,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk)
CourierStore.shared.wipe()
// Identity manager has cleared persisted identity data above
// Clear autocomplete state
@@ -105,6 +105,23 @@ private extension ChatViewModelBootstrapper {
)
}
}
// A message with no reachable transport that was handed to a courier
// shows a distinct "carried" state instead of sitting in "sending"
// forever. Never downgrade a confirmed receipt: the courier copy can
// race direct delivery when the peer reappears.
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
guard let viewModel else { return }
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
case .delivered, .read:
break
default:
SecureLogger.debug(
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
category: .session
)
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
}
}
viewModel.commandProcessor.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel)
@@ -19,6 +19,8 @@ extension DeliveryStatus {
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
case .carried:
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
case .delivered(let nickname, _):
return String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
@@ -80,6 +82,11 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .carried:
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.8))
case .delivered:
HStack(spacing: -2) {
Image(systemName: "checkmark")
@@ -120,6 +127,7 @@ struct DeliveryStatusView: View {
let statuses: [DeliveryStatus] = [
.sending,
.sent,
.carried,
.delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"),
+23
View File
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
@@ -88,6 +91,23 @@ struct ContentHeaderView: View {
}()
HStack(spacing: 2) {
if carriedMailCount > 0 {
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.8))
.headerTapTarget()
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
locale: .current,
carriedMailCount
)
)
.help(
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
)
}
if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
@@ -214,6 +234,9 @@ struct ContentHeaderView: View {
// Type.
.frame(height: headerHeight)
.padding(.horizontal, 12)
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel)
+1 -1
View File
@@ -139,7 +139,7 @@ struct MediaMessageView: View {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .read, .delivered, .failed:
case .sent, .carried, .read, .delivered, .failed:
break
}
}
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read
// failed < sending < sent < carried < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.failed(reason: "test"),
.sending,
.sent,
.carried,
.partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()),
.read(by: "C", at: Date())
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3)
case .delivered: #expect(index == 4)
case .read: #expect(index == 5)
case .carried: #expect(index == 3)
case .partiallyDelivered: #expect(index == 4)
case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
//
// CourierStoreTests.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 BitFoundation
@testable import bitchat
struct CourierStoreTests {
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
private func makeStore(now: Date = baseDate) -> CourierStore {
CourierStore(persistsToDisk: false, now: { now })
}
/// Store whose clock can be advanced by tests.
private final class Clock {
var now: Date
init(_ now: Date) { self.now = now }
}
private func makeEnvelope(
recipientKey: Data = Data(repeating: 0xB0, count: 32),
sealedAt: Date = baseDate,
lifetime: TimeInterval = 60 * 60,
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
) -> CourierEnvelope {
CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientKey,
epochDay: CourierEnvelope.epochDay(for: sealedAt)
),
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
ciphertext: ciphertext
)
}
private let depositorA = Data(repeating: 0xA1, count: 32)
private let depositorB = Data(repeating: 0xA2, count: 32)
// MARK: - Deposit and handover
@Test func depositThenTakeForRecipient() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
let taken = store.takeEnvelopes(for: recipientKey)
#expect(taken == [envelope])
// Handover removes the envelope.
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
@Test func takeIgnoresOtherRecipients() {
let store = makeStore()
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
store.deposit(envelope, from: depositorA)
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
}
@Test func duplicateDepositIsIdempotent() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
#expect(store.deposit(envelope, from: depositorA))
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
}
// MARK: - Validity
@Test func rejectsExpiredAndOversizedAndMalformed() {
let store = makeStore()
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
#expect(!store.deposit(expired, from: depositorA))
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
#expect(!store.deposit(oversized, from: depositorA))
let badTag = CourierEnvelope(
recipientTag: Data(repeating: 0, count: 4),
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
ciphertext: Data(repeating: 1, count: 16)
)
#expect(!store.deposit(badTag, from: depositorA))
}
@Test func rejectsExpiryBeyondPolicyLifetime() {
let store = makeStore()
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
#expect(!store.deposit(pinned, from: depositorA))
}
// MARK: - Quotas
@Test func perDepositorQuota() {
let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA))
}
#expect(!store.deposit(makeEnvelope(), from: depositorA))
// A different depositor still has room.
#expect(store.deposit(makeEnvelope(), from: depositorB))
}
@Test func totalQuotaEvictsOldestFirst() {
let store = makeStore()
let firstRecipient = Data(repeating: 0xD0, count: 32)
let first = makeEnvelope(recipientKey: firstRecipient)
store.deposit(first, from: depositorA)
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
var deposited = 1
var depositorByte: UInt8 = 1
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
#expect(store.deposit(makeEnvelope(), from: depositor))
deposited += 1
}
depositorByte += 1
}
// The first envelope was evicted to make room.
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
}
// MARK: - Expiry over time
@Test func expiredEnvelopesAreNotHandedOver() {
let clock = Clock(Self.baseDate)
let store = CourierStore(persistsToDisk: false, now: { clock.now })
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
clock.now = Self.baseDate.addingTimeInterval(7200)
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Panic wipe
@Test func wipeDropsEverything() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
store.wipe()
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Persistence
@Test func persistsAndReloadsAcrossInstances() throws {
// Isolated on-disk location so the test never touches the real store.
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("envelopes.json")
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
let recipientKey = Data(repeating: 0xE0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(first.deposit(envelope, from: depositorA))
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
}
}
@@ -0,0 +1,636 @@
//
// CourierEndToEndTests.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 Combine
import CoreBluetooth
import BitFoundation
@testable import bitchat
/// Three-node courier flow exercised through real BLEService instances with
/// packets ferried in-process: Alice deposits a sealed envelope with Carol
/// while Bob is unreachable; Carol hands it over when Bob announces; Bob
/// opens it and sees Alice's message in the right DM thread.
struct CourierEndToEndTests {
// 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 }
}
func count(ofType type: MessageType) -> Int {
lock.lock(); defer { lock.unlock() }
return packets.filter { $0.type == type.rawValue }.count
}
func all(ofType type: MessageType) -> [BitchatPacket] {
lock.lock(); defer { lock.unlock() }
return packets.filter { $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(identityManager: MockIdentityManager? = nil) -> BLEService {
let keychain = MockKeychain()
let identityManager = identityManager ?? MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
)
service.courierStore = CourierStore(persistsToDisk: false)
return service
}
/// Handling any packet from a peer preseeds it as a connected,
/// verified entry in the receiving service's registry.
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)
}
// MARK: - Tests
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
// Alice and Carol are mutual favorites; trust policy is exercised
// separately in depositFromUntrustedPeerIsRejected.
carol.courierDepositPolicy = { _ in true }
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
// Alice can see Carol; Bob is nowhere on the mesh.
preseedConnectedPeer(carol, in: alice)
// 1. Alice seals to Bob's static key and deposits with Carol.
#expect(alice.sendCourierMessage(
"the camp moved north",
messageID: "courier-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))
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
// 3. Later, Bob announces near Carol handover fires.
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
// 4. Ferry the handover to Bob; he opens the envelope.
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)
// Sender resolves to Alice's stable mesh identity, not the courier's.
#expect(delivered.peerID == alice.myPeerID)
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
#expect(message.messageID == "courier-msg-1")
#expect(message.content == "the camp moved north")
}
@Test func courieredMailFromBlockedSenderIsDropped() async throws {
let alice = makeService()
let carol = makeService()
let bobIdentity = MockIdentityManager(MockKeychain())
let bob = makeService(identityManager: bobIdentity)
carol.courierDepositPolicy = { _ in true }
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)
// Bob blocked Alice by her stable Noise identity while she was away.
bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true)
#expect(alice.sendCourierMessage(
"you should not see this",
messageID: "courier-msg-blocked-sender",
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))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let announcePacket = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(announcePacket, 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))
// Bob opens the envelope but the sealed sender is blocked, and it
// must never reach the UI. The live block check can't cover this: the
// sender is absent from Bob's registry, so no fingerprint resolves at
// delivery time.
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
let delivered = await TestHelpers.waitUntil(
{ !bobDelegate.snapshot().isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!delivered)
}
@Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
carol.courierDepositPolicy = { _ in true }
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)
#expect(alice.sendCourierMessage(
"hold until verified",
messageID: "courier-msg-unverified-announce",
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))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
let forgedAnnounce = try makeUnsignedAnnounce(from: bob)
carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!leakedOnUnverifiedAnnounce)
#expect(!carol.courierStore.isEmpty)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
}
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
carol.courierDepositPolicy = { _ in true }
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)
#expect(alice.sendCourierMessage(
"hold for a direct encounter",
messageID: "courier-msg-relayed-announce",
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))
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
)
#expect(carried)
bob.sendBroadcastAnnounce()
let announced = await TestHelpers.waitUntil(
{ bobOut.first(ofType: .announce) != nil },
timeout: TestConstants.defaultTimeout
)
#expect(announced)
let directAnnounce = try #require(bobOut.first(ofType: .announce))
// A relayed copy has a decremented TTL but a still-valid signature
// (TTL is excluded from announce signatures). Envelopes are removed
// from the store optimistically, so handover must wait for a direct
// encounter instead of chasing a multi-hop path.
var relayedAnnounce = directAnnounce
relayedAnnounce.ttl = directAnnounce.ttl - 1
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 },
timeout: TestConstants.shortTimeout
)
#expect(!leakedOnRelayedAnnounce)
#expect(!carol.courierStore.isEmpty)
// The relayed copy consumed the original announce's dedup key
// (sender/timestamp/payload TTL excluded), so the direct handover
// needs a fresh announce. Wait out the 1s announce throttle first.
try await Task.sleep(nanoseconds: 1_100_000_000)
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
timeout: TestConstants.defaultTimeout
)
#expect(reannounced)
let freshAnnounce = try #require(
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
)
carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
#expect(carol.courierStore.isEmpty)
}
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
let alice = makeService()
let carol = makeService()
preseedConnectedPeer(carol, in: alice)
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
#expect(!alice.sendCourierMessage(
"this cannot be sealed",
messageID: "courier-msg-invalid-key",
recipientNoiseKey: Data(repeating: 0x01, count: 8),
via: [carol.myPeerID]
))
let queuedPacket = await TestHelpers.waitUntil(
{ aliceOut.first(ofType: .courierEnvelope) != nil },
timeout: TestConstants.shortTimeout
)
#expect(!queuedPacket)
}
@Test func depositFromUntrustedPeerIsRejected() async throws {
let carol = makeService()
carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1"))
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
let now = Date()
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: bobKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: sealed
)
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: carol.myPeerID.id),
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: try #require(envelope.encode()),
signature: nil,
ttl: 1
)
carol._test_handlePacket(packet, fromPeerID: alicePeerID)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!stored)
}
@Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws {
let alice = makeService()
let carol = makeService()
let mallory = makeService()
preseedConnectedPeer(alice, in: carol)
preseedConnectedPeer(mallory, in: carol)
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
carol.courierDepositPolicy = { depositorKey in
depositorKey == trustedAliceKey
}
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof"))
let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
let now = Date()
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: bobKey,
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: sealed
)
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alice.myPeerID.id) ?? Data(),
recipientID: Data(hexString: carol.myPeerID.id),
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: try #require(envelope.encode()),
signature: nil,
ttl: 1
)
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
)
#expect(!stored)
}
private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: "Unsigned",
noisePublicKey: service.noiseStaticPublicKeyData(),
signingPublicKey: service.noiseSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
return BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
}
}
// MARK: - Router courier selection
/// Minimal transport stub for exercising MessageRouter's courier deposit
/// logic without BLE plumbing.
private final class CourierCaptureTransport: Transport {
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?
var snapshots: [TransportPeerSnapshot] = []
private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = []
private(set) var directSends: [String] = []
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
Just(snapshots).eraseToAnyPublisher()
}
func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots }
var myPeerID = PeerID(str: "00000000000000aa")
var myNickname = "stub"
func setNickname(_ nickname: String) {}
func startServices() {}
func stopServices() {}
func emergencyDisconnectAll() {}
func isPeerConnected(_ peerID: PeerID) -> Bool {
snapshots.contains { $0.peerID == peerID && $0.isConnected }
}
func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) }
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) {}
func sendMessage(_ content: String, mentions: [String]) {}
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
directSends.append(messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
func sendBroadcastAnnounce() {}
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
courierSends.append((messageID, recipientNoiseKey, couriers))
return true
}
}
struct MessageRouterCourierTests {
@Test @MainActor
func unreachablePeerMessageGoesToTrustedCouriersOnly() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let carolKey = Data(repeating: 0xC0, count: 32)
let carolID = PeerID(publicKey: carolKey)
let daveKey = Data(repeating: 0xD0, count: 32)
let daveID = PeerID(publicKey: daveKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
// Carol: connected mutual favorite eligible courier.
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()),
// Dave: connected but not trusted never a courier.
TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date())
]
let directory = CourierDirectory(
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
isTrustedCourier: { $0 == carolKey }
)
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1")
#expect(transport.directSends.isEmpty)
#expect(transport.courierSends.count == 1)
#expect(transport.courierSends.first?.messageID == "m1")
#expect(transport.courierSends.first?.recipientKey == bobKey)
#expect(transport.courierSends.first?.couriers == [carolID])
#expect(carried == ["m1"])
}
@Test @MainActor
func noCourierDepositWithoutKnownRecipientKey() {
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date())
]
let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true })
let router = MessageRouter(transports: [transport], courierDirectory: directory)
var carried: [String] = []
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2")
#expect(transport.courierSends.isEmpty)
#expect(carried.isEmpty)
}
@Test @MainActor
func reachablePeerSkipsCourier() {
let bobKey = Data(repeating: 0xB0, count: 32)
let bobID = PeerID(publicKey: bobKey)
let transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date())
]
let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true })
let router = MessageRouter(transports: [transport], courierDirectory: directory)
router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3")
#expect(transport.directSends == ["m3"])
#expect(transport.courierSends.isEmpty)
}
}
+107
View File
@@ -0,0 +1,107 @@
//
// NoiseCourierTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
/// One-way Noise X envelopes: encryption to a known static key without an
/// interactive handshake, used by the courier store-and-forward path.
struct NoiseCourierTests {
@Test func sealAndOpenRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let payload = Data("meet at the north gate".utf8)
let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
let opened = try bob.openCourierPayload(sealed)
#expect(opened.payload == payload)
// The X pattern authenticates the sender: Bob learns Alice's real static key.
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
}
@Test func wrongRecipientCannotOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let carol = NoiseEncryptionService(keychain: MockKeychain())
let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
#expect(throws: (any Error).self) {
_ = try carol.openCourierPayload(sealed)
}
}
@Test func tamperedEnvelopeFailsToOpen() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
sealed[sealed.count - 1] ^= 0x01
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(sealed)
}
}
@Test func senderIdentityCannotBeForged() throws {
// The encrypted static key inside the envelope is bound by the ss DH;
// splicing one envelope's ephemeral prefix onto another must fail.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = bob.getStaticPublicKeyData()
let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
// e (32 bytes) from Mallory's envelope + rest from Alice's.
let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32)
#expect(throws: (any Error).self) {
_ = try bob.openCourierPayload(Data(spliced))
}
}
@Test func sealRejectsInvalidRecipientKey() {
let alice = NoiseEncryptionService(keychain: MockKeychain())
#expect(throws: (any Error).self) {
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32))
}
#expect(throws: (any Error).self) {
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8))
}
}
@Test func emptyAndLargePayloadsRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = bob.getStaticPublicKeyData()
let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey)
#expect(try bob.openCourierPayload(empty).payload.isEmpty)
let large = Data((0..<8192).map { UInt8($0 % 251) })
let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey)
#expect(try bob.openCourierPayload(sealed).payload == large)
}
@Test func envelopesAreNotLinkableAcrossSends() throws {
// Fresh ephemeral per seal: same payload to the same recipient must
// produce entirely different ciphertexts.
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let payload = Data("same message".utf8)
let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
#expect(a != b)
#expect(a.prefix(32) != b.prefix(32))
}
}
@@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isDirectAnnounce == true)
#expect(result?.isVerified == true)
#expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
#expect(recorder.barrierCount == 1)
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.isEmpty)
#expect(recorder.barrierCount == 1)
#expect(recorder.upsertCalls.isEmpty)
@@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests {
recorder.signatureValid = false
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
@@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.isDirectAnnounce == false)
#expect(result?.isVerified == true)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false)
#expect(recorder.uiEventDeliveries.count == 1)
@@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests {
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
let result = handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
@@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests {
#expect(selection.centralIDs == Set(["c2"]))
}
@Test
func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["target-p", "bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"target-p": target,
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs == Set(["target-p"]))
#expect(selection.centralIDs.isEmpty)
}
@Test
func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs == Set(["target-c"]))
}
@Test
func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: .central("target-c"),
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs.isEmpty)
}
@Test
func directedSendExcludesAllLinksToIngressPeer() {
let selection = BLEFanoutSelector.selectLinks(
@@ -0,0 +1,147 @@
//
// CourierEnvelope.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
private import CryptoKit
/// TLV payload for store-and-forward courier envelopes.
///
/// A courier envelope lets a mutual favorite physically carry an encrypted
/// message to a peer who is currently offline. The envelope is opaque to the
/// courier: the only routing information is a rotating recipient tag derived
/// from the recipient's Noise static public key and the UTC day, so envelopes
/// addressed to the same peer on different days do not correlate for
/// observers who don't already know that peer's public key.
public struct CourierEnvelope: Equatable {
/// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated.
public let recipientTag: Data
/// Milliseconds since epoch after which the envelope must be discarded.
public let expiry: UInt64
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
public let ciphertext: Data
public static let tagLength = 16
/// Couriered messages are text-sized; media transfers are out of scope.
public static let maxCiphertextBytes = 16 * 1024
/// Matches the outbox retention policy in MessageRouter.
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
private enum TLVType: UInt8 {
case recipientTag = 0x01
case expiry = 0x02
case ciphertext = 0x03
}
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
}
public var isExpired: Bool {
isExpired(at: Date())
}
public func isExpired(at date: Date) -> Bool {
UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry
}
public func encode() -> Data? {
guard recipientTag.count == Self.tagLength else { return nil }
guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count)
encoded.append(TLVType.recipientTag.rawValue)
appendBE(UInt16(recipientTag.count), into: &encoded)
encoded.append(recipientTag)
encoded.append(TLVType.expiry.rawValue)
appendBE(UInt16(8), into: &encoded)
appendBE(expiry, into: &encoded)
encoded.append(TLVType.ciphertext.rawValue)
appendBE(UInt16(ciphertext.count), into: &encoded)
encoded.append(ciphertext)
return encoded
}
public static func decode(_ data: Data) -> CourierEnvelope? {
var cursor = data.startIndex
let end = data.endIndex
var recipientTag: Data?
var expiry: UInt64?
var ciphertext: 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 .recipientTag:
guard length == tagLength else { return nil }
recipientTag = Data(value)
case .expiry:
guard length == 8 else { return nil }
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .ciphertext:
guard length > 0, length <= maxCiphertextBytes else { return nil }
ciphertext = Data(value)
case nil:
// Unknown TLV: skip for forward compatibility.
continue
}
}
guard let recipientTag, let expiry, let ciphertext else { return nil }
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
}
// MARK: - Recipient Tags
private static let tagContext = Data("bitchat-courier-tag-v1".utf8)
/// UTC day number used to rotate recipient tags.
public static func epochDay(for date: Date) -> UInt32 {
UInt32(max(0, date.timeIntervalSince1970) / 86_400)
}
/// Rotating recipient hint for a given day. Computable only by parties
/// who already know the recipient's Noise static public key.
public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
var message = tagContext
withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey))
return Data(mac).prefix(tagLength)
}
/// Tags to test when checking whether an envelope is addressed to a peer.
/// Covers the adjacent days so envelopes sealed near midnight (or across
/// modest clock skew) still match while being carried.
public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] {
let day = epochDay(for: date)
return [day == 0 ? 0 : day - 1, day, day + 1].map {
recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0)
}
}
}
@@ -11,17 +11,20 @@ import struct Foundation.Date
public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case carried // Sealed envelope handed to a courier; best-effort physical delivery
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .carried:
return "Carried by a friend"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
@@ -12,8 +12,9 @@
public enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case message = 0x02 // Public chat message
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
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
@@ -0,0 +1,122 @@
//
// CourierEnvelopeTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import BitFoundation
struct CourierEnvelopeTests {
private func makeEnvelope(
tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength),
expiry: UInt64 = 1_900_000_000_000,
ciphertext: Data = Data(repeating: 0x42, count: 128)
) -> CourierEnvelope {
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
}
// MARK: - Codec
@Test func roundTrip() throws {
let envelope = makeEnvelope()
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
}
@Test func roundTripAtMaxCiphertextSize() throws {
let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes))
let encoded = try #require(envelope.encode())
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == envelope)
}
@Test func encodeRejectsInvalidFields() {
#expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil)
#expect(makeEnvelope(ciphertext: Data()).encode() == nil)
#expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil)
}
@Test func decodeRejectsMissingFields() throws {
// Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only.
let encoded = try #require(makeEnvelope().encode())
let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8)
#expect(CourierEnvelope.decode(Data(truncated)) == nil)
}
@Test func decodeRejectsTruncatedValue() throws {
let encoded = try #require(makeEnvelope().encode())
#expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil)
}
@Test func decodeSkipsUnknownTLVs() throws {
var encoded = try #require(makeEnvelope().encode())
// Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it.
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
let decoded = try #require(CourierEnvelope.decode(encoded))
#expect(decoded == makeEnvelope())
}
@Test func decodeOffsetSlice() throws {
// Decoder must handle slices with non-zero startIndex.
let encoded = try #require(makeEnvelope().encode())
let padded = Data([0xFF, 0xFF]) + encoded
let slice = padded.dropFirst(2)
#expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope())
#expect(CourierEnvelope.decode(slice) == makeEnvelope())
}
// MARK: - Expiry
@Test func expiryComparison() {
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
#expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired)
#expect(makeEnvelope(expiry: nowMs - 60_000).isExpired)
#expect(makeEnvelope(expiry: 0).isExpired)
}
// MARK: - Recipient Tags
@Test func tagIsDeterministicPerKeyAndDay() {
let key = Data(repeating: 0x11, count: 32)
let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
#expect(tag1 == tag2)
#expect(tag1.count == CourierEnvelope.tagLength)
}
@Test func tagRotatesAcrossDaysAndKeys() {
let key = Data(repeating: 0x11, count: 32)
let otherKey = Data(repeating: 0x22, count: 32)
let day: UInt32 = 20_000
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
!= CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
!= CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day))
}
@Test func candidateTagsCoverAdjacentDays() {
let key = Data(repeating: 0x33, count: 32)
let date = Date(timeIntervalSince1970: 1_750_000_000)
let day = CourierEnvelope.epochDay(for: date)
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date)
#expect(candidates.count == 3)
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1)))
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)))
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)))
}
@Test func sealedYesterdayMatchesToday() {
// An envelope sealed late on day D must still match the recipient on day D+1.
let key = Data(repeating: 0x44, count: 32)
let sealedAt = Date(timeIntervalSince1970: 1_750_000_000)
let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60)
let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt))
#expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag))
}
}