mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Compare commits
2
Commits
feat/courier
...
v1.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66c591f8e | ||
|
|
75da63c9d7 |
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.3
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
Generated
+4
-4
@@ -561,7 +561,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -620,7 +620,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -655,7 +655,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -749,7 +749,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||
/// geohash/custom relay alone must not count — sends would still queue.
|
||||
@Published private(set) var isDMRelayConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
@@ -1087,6 +1091,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func updateConnectionStatus() {
|
||||
isConnected = relays.contains { $0.isConnected }
|
||||
// Relay URLs are normalized before entries are created, so direct
|
||||
// set membership is sound.
|
||||
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
|
||||
}
|
||||
|
||||
/// A relay that drops before sending EOSE must not stall initial-load
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -889,6 +903,10 @@ final class BLEService: NSObject {
|
||||
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)
|
||||
if packetToSend.type == MessageType.fileTransfer.rawValue {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2469,6 +2490,146 @@ 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.
|
||||
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
|
||||
}
|
||||
// A present sender resolves to their live mesh thread via the
|
||||
// derived short ID. An absent sender — the usual courier case —
|
||||
// uses the full noise-key ID so the message lands on the stable
|
||||
// favorite conversation instead of an unresolvable short-ID
|
||||
// thread labeled "Unknown".
|
||||
let shortID = PeerID(publicKey: senderStaticKey)
|
||||
let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
|
||||
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: 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 {
|
||||
@@ -2601,6 +2762,9 @@ extension BLEService {
|
||||
// MARK: Private Message Handling
|
||||
|
||||
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
|
||||
// Sessions and wire recipient IDs are keyed by the short 16-hex form;
|
||||
// callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
|
||||
let recipientID = recipientID.toShort()
|
||||
SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)
|
||||
|
||||
// Check if we have an established Noise session
|
||||
@@ -2954,6 +3118,9 @@ extension BLEService {
|
||||
case .fileTransfer:
|
||||
handleFileTransfer(packet, from: senderID)
|
||||
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
@@ -3015,7 +3182,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
|
||||
|
||||
@@ -51,8 +51,9 @@ protocol CommandContextProvider: AnyObject {
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
|
||||
// MARK: - Favorites
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
/// real noise key and notifies the peer over mesh or Nostr.
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -338,31 +339,28 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
if add {
|
||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
// Resolve current state by the peer's real noise key. The resolved
|
||||
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
||||
// noise-key ID (offline favorite row) — never the noise key itself.
|
||||
let isCurrentlyFavorite: Bool
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
guard add != isCurrentlyFavorite else {
|
||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
||||
}
|
||||
|
||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
|
||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
peerNostrPublicKey: String? = nil
|
||||
) {
|
||||
let existing = favorites[peerNoisePublicKey]
|
||||
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||
// Callers that can't resolve the live nickname pass the "Unknown"
|
||||
// placeholder (e.g. a notification arriving before the announce);
|
||||
// never let it clobber a real stored nickname.
|
||||
let incoming = peerNickname.flatMap { name in
|
||||
(name.isEmpty || name == "Unknown") ? nil : name
|
||||
}
|
||||
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
|
||||
|
||||
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||
|
||||
|
||||
@@ -2,11 +2,37 @@ 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
|
||||
// Offline favorites are addressed by the full 64-hex
|
||||
// noise-key ID, which carries the key itself; the favorites
|
||||
// lookup only resolves short 16-hex IDs.
|
||||
peerID.noiseKey
|
||||
?? 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 +40,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 +63,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(
|
||||
@@ -89,11 +128,47 @@ final class MessageRouter {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
// may never come. Double delivery is harmless — receivers dedup
|
||||
// by message ID, and delivered/read acks never downgrade.
|
||||
if !transport.canDeliverPromptly(to: peerID) {
|
||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||
}
|
||||
} else {
|
||||
var unsent = message
|
||||
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 deliver promptly — the peer is
|
||||
/// unreachable, or only reachable through a send queue waiting on
|
||||
/// internet: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -370,6 +370,49 @@ final class NoiseEncryptionService {
|
||||
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() {
|
||||
// Clear from keychain
|
||||
|
||||
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
/// still queue.
|
||||
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
||||
|
||||
@MainActor
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +56,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
// Mirror of the relay manager's connection state, cached here because
|
||||
// canDeliverPromptly is called synchronously off the main actor.
|
||||
private var relaysConnected = false
|
||||
private var relayConnectivityCancellable: AnyCancellable?
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.reachablePeers = Set(reachable)
|
||||
}
|
||||
|
||||
relayConnectivityCancellable = self.dependencies.relayConnectivity()
|
||||
.sink { [weak self] connected in
|
||||
guard let self else { return }
|
||||
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -125,17 +142,23 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
// Check if exact match
|
||||
// Callers address peers by either the short 16-hex ID or the full
|
||||
// 64-hex noise key (offline favorites), so compare in short form.
|
||||
let short = peerID.toShort()
|
||||
return queue.sync {
|
||||
if reachablePeers.contains(peerID) { return true }
|
||||
// Check for short ID match
|
||||
if peerID.isShort {
|
||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
||||
}
|
||||
return false
|
||||
return reachablePeers.contains(where: { $0.toShort() == short })
|
||||
}
|
||||
}
|
||||
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
// A known npub makes a peer "reachable", but with no relay
|
||||
// connection a send only joins the local queue. Answering honestly
|
||||
// here lets the router hand a sealed copy to a courier in parallel
|
||||
// instead of waiting for internet that may never come.
|
||||
isPeerReachable(peerID) && queue.sync { relaysConnected }
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ protocol Transport: AnyObject {
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
/// Whether a send to this peer is likely to leave the device promptly.
|
||||
/// Distinct from reachability: Nostr claims any favorite with a known
|
||||
/// npub as reachable even with no relay connection, where a send only
|
||||
/// joins a queue waiting for internet that may never come.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
@@ -95,6 +100,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)
|
||||
@@ -105,6 +116,10 @@ protocol Transport: AnyObject {
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
// Reachability implies prompt delivery for transports that hand packets
|
||||
// straight to the radio; queue-backed transports override this.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
|
||||
|
||||
// Noise identity hooks default to inert for transports that do not carry
|
||||
// Noise sessions (e.g. NostrTransport).
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||
@@ -120,6 +135,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) {}
|
||||
|
||||
@@ -86,6 +86,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
var meshNoiseKeys: Set<Data> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
@@ -104,23 +105,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// Update fingerprint cache
|
||||
if let publicKey = peerInfo.noisePublicKey {
|
||||
meshNoiseKeys.insert(publicKey)
|
||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
// Phase 2: Add offline favorites that we actively favorite.
|
||||
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
|
||||
// the full 32-byte noise key, so dedup must compare noise keys — a
|
||||
// PeerID comparison between the two forms can never match.
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
if meshNoiseKeys.contains(favoriteKey) { continue }
|
||||
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
|
||||
// Skip if connected under different ID but same nickname
|
||||
let isConnectedByNickname = enrichedPeers.contains {
|
||||
$0.nickname == favorite.peerNickname && $0.isConnected
|
||||
}
|
||||
if isConnectedByNickname { continue }
|
||||
|
||||
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||
enrichedPeers.append(peer)
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
|
||||
// MARK: Favorites & notifications (shared with the other contexts)
|
||||
// MARK: Favorites (shared with the other contexts)
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Adds (or updates) a favorite in the favorites store.
|
||||
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
|
||||
/// Posts a generic local user notification.
|
||||
func postLocalNotification(title: String, body: String, identifier: String)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatNostrContext {
|
||||
@@ -98,57 +94,6 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
guard let context else { return }
|
||||
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
|
||||
|
||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
||||
|
||||
if isFavorite {
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: nostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
var extractedNostrPubkey: String?
|
||||
if let range = content.range(of: "NPUB:") {
|
||||
let suffix = content[range.upperBound...]
|
||||
let parts = suffix.components(separatedBy: "|")
|
||||
if let key = parts.first {
|
||||
extractedNostrPubkey = String(key)
|
||||
}
|
||||
} else if content.contains(":") {
|
||||
let parts = content.components(separatedBy: ":")
|
||||
if parts.count >= 3 {
|
||||
extractedNostrPubkey = String(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
||||
|
||||
if isFavorite && extractedNostrPubkey != nil {
|
||||
SecureLogger.info(
|
||||
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
|
||||
category: .session
|
||||
)
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: extractedNostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
context.postLocalNotification(
|
||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||
identifier: "fav-\(UUID().uuidString)"
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
guard let context else { return }
|
||||
|
||||
@@ -92,7 +92,6 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
|
||||
|
||||
// MARK: System messages
|
||||
func addSystemMessage(_ content: String)
|
||||
@@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
// MARK: Favorites & notifications
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// The persisted favorite relationship resolved from a short 16-hex mesh
|
||||
/// peer ID (matched against the IDs derived from stored noise keys).
|
||||
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Persists that the peer favorited/unfavorited us (favorites store write).
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
||||
/// Posts the incoming-private-message local notification.
|
||||
@@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
}
|
||||
|
||||
// `favoriteRelationship(forPeerID:)` is shared with
|
||||
// `ChatPeerIdentityContext`; its witness lives in
|
||||
// `ChatPeerIdentityCoordinator.swift`.
|
||||
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
@@ -245,10 +251,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
guard let noiseKey = Data(hexString: peerID.id) else { return }
|
||||
// Resolve the favorite behind this conversation. It may be keyed by
|
||||
// the full 64-hex noise-key ID (offline favorite row) or the short
|
||||
// 16-hex mesh ID — the raw hex bytes of a short ID are a routing ID,
|
||||
// never a noise key, so they must not be used as a favorites key.
|
||||
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
||||
let isConnected = context.isPeerConnected(peerID)
|
||||
let isReachable = context.isPeerReachable(peerID)
|
||||
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey)
|
||||
let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) }
|
||||
?? context.favoriteRelationship(forPeerID: peerID)
|
||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||
|
||||
@@ -405,9 +416,32 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the favorite's stored nickname when the sender resolved to a
|
||||
// known noise key; the Nostr display name is a geohash-scoped
|
||||
// fallback (e.g. "anon#678e") that would mislabel favorite-transport
|
||||
// DMs. Geohash conversations (nostr_ keys) keep the geo name.
|
||||
let senderName: String = {
|
||||
if let noiseKey = convKey.noiseKey,
|
||||
let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname,
|
||||
!favoriteNickname.isEmpty {
|
||||
return favoriteNickname
|
||||
}
|
||||
return context.displayNameForNostrPubkey(senderPubkey)
|
||||
}()
|
||||
|
||||
// Favorite notifications ride the PM channel over Nostr too; intercept
|
||||
// them so they update the relationship instead of rendering as text.
|
||||
if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") {
|
||||
handleFavoriteNotification(
|
||||
pm.content,
|
||||
from: convKey,
|
||||
senderNickname: senderName
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||
|
||||
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
||||
let message = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderName,
|
||||
@@ -486,93 +520,6 @@ final class ChatPrivateConversationCoordinator {
|
||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||
}
|
||||
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
actualSenderNoiseKey: Data?,
|
||||
senderNickname: String,
|
||||
targetPeerID: PeerID,
|
||||
messageTimestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
let messageContent = pm.content
|
||||
|
||||
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
|
||||
if let key = actualSenderNoiseKey {
|
||||
handleFavoriteNotificationFromMesh(
|
||||
messageContent,
|
||||
from: PeerID(hexData: key),
|
||||
senderNickname: senderNickname
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
|
||||
return
|
||||
}
|
||||
|
||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||
|
||||
var isViewingThisChat = false
|
||||
if context.selectedPrivateChatPeer == targetPeerID {
|
||||
isViewingThisChat = true
|
||||
} else if let selectedPeer = context.selectedPrivateChatPeer,
|
||||
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
|
||||
let key = actualSenderNoiseKey,
|
||||
selectedPeerNoiseKey == key {
|
||||
isViewingThisChat = true
|
||||
}
|
||||
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderNickname,
|
||||
content: messageContent,
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: context.nickname,
|
||||
senderPeerID: targetPeerID,
|
||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||
)
|
||||
|
||||
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
|
||||
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
|
||||
|
||||
context.sendDeliveryAckViaNostrEmbedded(
|
||||
message,
|
||||
wasReadBefore: wasReadBefore,
|
||||
senderPubkey: senderPubkey,
|
||||
key: actualSenderNoiseKey
|
||||
)
|
||||
|
||||
if wasReadBefore {
|
||||
// No-op.
|
||||
} else if isViewingThisChat {
|
||||
handleViewingThisChat(
|
||||
message,
|
||||
targetPeerID: targetPeerID,
|
||||
key: actualSenderNoiseKey,
|
||||
senderPubkey: senderPubkey
|
||||
)
|
||||
} else {
|
||||
markAsUnreadIfNeeded(
|
||||
shouldMarkAsUnread: shouldMarkAsUnread,
|
||||
targetPeerID: targetPeerID,
|
||||
key: actualSenderNoiseKey,
|
||||
isRecentMessage: isRecentMessage,
|
||||
senderNickname: senderNickname,
|
||||
messageContent: messageContent
|
||||
)
|
||||
}
|
||||
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
||||
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
||||
@@ -583,7 +530,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
|
||||
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
|
||||
handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -706,7 +653,10 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
/// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either
|
||||
/// transport. `peerID` must resolve to a noise key — a full 64-hex ID or
|
||||
/// one the unified peer list knows; otherwise the notification is dropped.
|
||||
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
let isFavorite = content.hasPrefix("[FAVORITED]")
|
||||
let parts = content.split(separator: ":")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -109,11 +109,6 @@ extension ChatViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||
|
||||
@@ -121,25 +121,6 @@ extension ChatViewModel {
|
||||
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
actualSenderNoiseKey: Data?,
|
||||
senderNickname: String,
|
||||
targetPeerID: PeerID,
|
||||
messageTimestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
privateConversationCoordinator.handlePrivateMessage(
|
||||
payload,
|
||||
actualSenderNoiseKey: actualSenderNoiseKey,
|
||||
senderNickname: senderNickname,
|
||||
targetPeerID: targetPeerID,
|
||||
messageTimestamp: messageTimestamp,
|
||||
senderPubkey: senderPubkey
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
privateConversationCoordinator.handlePrivateMessage(message)
|
||||
@@ -190,8 +171,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotificationFromMesh(
|
||||
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotification(
|
||||
content,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname
|
||||
|
||||
@@ -442,8 +442,7 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
||||
/// store. Lives here because the inbound DM path needs it per message;
|
||||
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
||||
/// store. Lives here because the inbound DM path needs it per message.
|
||||
@MainActor
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
guard let context else { return nil }
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,34 +608,6 @@ struct GeoPresenceTrackerTests {
|
||||
#expect(stamped > stale)
|
||||
#expect(context.appendedGeohashMessages.count == 1)
|
||||
}
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let noiseKey = Data(repeating: 0x42, count: 32)
|
||||
// The favorites store bridges the sender's npub back to a Noise key.
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
||||
noiseKey: noiseKey,
|
||||
nostrPublicKey: sender.npub
|
||||
)
|
||||
|
||||
coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex)
|
||||
|
||||
#expect(context.addedFavorites.count == 1)
|
||||
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
|
||||
#expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex)
|
||||
#expect(context.addedFavorites.first?.nickname == "alice")
|
||||
#expect(context.postedLocalNotifications.count == 1)
|
||||
#expect(context.postedLocalNotifications.first?.title == "New Favorite")
|
||||
#expect(context.postedLocalNotifications.first?.body == "alice favorited you")
|
||||
|
||||
// Unfavorite: no store write, but the removal notification still posts.
|
||||
coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex)
|
||||
#expect(context.addedFavorites.count == 1)
|
||||
#expect(context.postedLocalNotifications.last?.title == "Favorite Removed")
|
||||
#expect(context.postedLocalNotifications.last?.body == "alice unfavorited you")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
|
||||
|
||||
@@ -225,6 +225,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
favoriteRelationshipsByNoiseKey[noiseKey]
|
||||
}
|
||||
|
||||
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
|
||||
favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value
|
||||
}
|
||||
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
|
||||
}
|
||||
@@ -549,14 +553,14 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
|
||||
func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
|
||||
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
|
||||
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
#expect(context.peerFavoritedUsUpdates.count == 1)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
||||
@@ -568,16 +572,79 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
noiseKey: noiseKey,
|
||||
theyFavoritedUs: true
|
||||
)
|
||||
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
#expect(context.peerFavoritedUsUpdates.count == 2)
|
||||
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
||||
|
||||
// [UNFAVORITED] transition announces again.
|
||||
coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice")
|
||||
coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice")
|
||||
#expect(context.peerFavoritedUsUpdates.last?.favorited == false)
|
||||
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
|
||||
}
|
||||
|
||||
/// A Nostr DM whose sender resolved to a known noise key must be labeled
|
||||
/// with the favorite's nickname, not the geohash-scoped anon fallback.
|
||||
@Test @MainActor
|
||||
func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xDA, count: 32)
|
||||
let convKey = PeerID(hexData: noiseKey)
|
||||
let senderPubkey = "0badc0de00112233"
|
||||
// No displayNamesByPubkey entry: the geo fallback would be "anon".
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
||||
noiseKey: noiseKey,
|
||||
nostrPublicKey: "npub1bob",
|
||||
nickname: "bob",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true
|
||||
)
|
||||
|
||||
let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(context.privateChats[convKey]?.first?.sender == "bob")
|
||||
}
|
||||
|
||||
/// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey
|
||||
/// path; they must update the relationship, not render as chat text.
|
||||
@Test @MainActor
|
||||
func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xEE, count: 32)
|
||||
// The inbound pipeline resolves known favorites to their noise-key ID.
|
||||
let convKey = PeerID(hexData: noiseKey)
|
||||
let senderPubkey = "feedface99887766"
|
||||
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
||||
|
||||
let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(context.peerFavoritedUsUpdates.count == 1)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
|
||||
#expect(context.privateChats[convKey, default: []].isEmpty)
|
||||
#expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
@@ -602,6 +669,32 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
|
||||
/// Same as above, but the conversation is keyed by the SHORT mesh ID —
|
||||
/// the DM window was opened while the peer was on mesh, then they went
|
||||
/// out of range. The favorite must resolve via the derived short ID and
|
||||
/// route over Nostr instead of failing "peer not reachable".
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xCE, count: 32)
|
||||
let shortID = PeerID(publicKey: noiseKey)
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
||||
noiseKey: noiseKey,
|
||||
nostrPublicKey: "npub1bob",
|
||||
nickname: "bob",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true
|
||||
)
|
||||
|
||||
coordinator.sendPrivateMessage("hello again", to: shortID)
|
||||
|
||||
#expect(context.routedPrivateMessages.map(\.content) == ["hello again"])
|
||||
#expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent)
|
||||
#expect(context.privateChats[shortID]?.first?.recipientNickname == "bob")
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,8 +655,10 @@ struct ChatViewModelNostrExtensionTests {
|
||||
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
||||
}
|
||||
|
||||
/// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
|
||||
/// out of the conversation transcript.
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
|
||||
func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let identity = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
||||
@@ -664,19 +666,33 @@ struct ChatViewModelNostrExtensionTests {
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: identity.npub,
|
||||
peerNickname: "Before"
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
defer {
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
viewModel.handleFavoriteNotification(
|
||||
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
|
||||
from: identity.publicKeyHex
|
||||
// The inbound pipeline resolves a known sender to their noise-key ID.
|
||||
let convKey = PeerID(hexData: noiseKey)
|
||||
let payloadData = try #require(
|
||||
PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode()
|
||||
)
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: identity.publicKeyHex,
|
||||
convKey: convKey,
|
||||
id: identity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
#expect(relationship?.peerNickname == "Alice")
|
||||
#expect(relationship?.theyFavoritedUs == true)
|
||||
#expect(relationship?.isMutual == true)
|
||||
#expect(relationship?.peerNostrPublicKey == identity.npub)
|
||||
#expect(relationship?.isFavorite == true)
|
||||
#expect(viewModel.privateChats[convKey, default: []].isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -303,6 +303,50 @@ struct CommandProcessorTests {
|
||||
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
}
|
||||
|
||||
/// /fav must go through toggleFavorite (which persists by the real noise
|
||||
/// key) — not write the hex peer ID into the favorites store, and not
|
||||
/// send a second favorite notification.
|
||||
@MainActor
|
||||
@Test func favoriteCommandTogglesWithoutDirectStoreWrite() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let processor = CommandProcessor(
|
||||
contextProvider: context,
|
||||
meshService: MockTransport(),
|
||||
identityManager: identityManager
|
||||
)
|
||||
let peerID = PeerID(str: "00aa00bb00cc00dd")
|
||||
context.nicknameToPeerID["alice"] = peerID
|
||||
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/fav alice")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "added alice to favorites")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(context.toggledFavorites == [peerID])
|
||||
#expect(context.favoriteNotifications.isEmpty)
|
||||
// The 8-byte routing ID must never be stored as a "noise key".
|
||||
let bogusKey = Data(hexString: peerID.id)!
|
||||
#expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil)
|
||||
|
||||
// Unfavoriting someone who is not a favorite is a no-op.
|
||||
let unfavResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/unfav alice")
|
||||
}
|
||||
switch unfavResult {
|
||||
case .success(let message):
|
||||
#expect(message == "alice is not a favorite")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(context.toggledFavorites == [peerID])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
|
||||
@@ -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,730 @@
|
||||
//
|
||||
// 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)
|
||||
// Alice is absent from Bob's mesh, so the sender resolves to her
|
||||
// full noise-key ID — the stable favorite conversation — not the
|
||||
// short mesh ID (which Bob couldn't resolve to a nickname) and not
|
||||
// the courier's identity.
|
||||
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
|
||||
#expect(delivered.peerID != carol.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 }
|
||||
}
|
||||
// Nostr-style reachability: claimed for peers with no live link (known
|
||||
// npub), where prompt delivery additionally needs a relay connection.
|
||||
var reachablePeers: Set<PeerID> = []
|
||||
var promptDelivery = true
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
isPeerConnected(peerID) || reachablePeers.contains(peerID)
|
||||
}
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
isPeerReachable(peerID) && promptDelivery
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// The production directory must resolve both ID forms: a 64-hex
|
||||
/// noise-key ID (offline favorite row) carries the key itself, and a
|
||||
/// short 16-hex ID resolves through the favorites store.
|
||||
@Test @MainActor
|
||||
func favoritesBackedDirectoryResolvesBothIDForms() {
|
||||
let directory = CourierDirectory.favoritesBacked()
|
||||
let bobKey = Data(repeating: 0xB7, count: 32)
|
||||
|
||||
#expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey)
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob")
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) }
|
||||
#expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
/// A peer can be "reachable" through a transport that cannot deliver
|
||||
/// promptly (Nostr claims any favorite with a known npub, even with no
|
||||
/// relay connection). The queued send must not shadow the courier: a
|
||||
/// sealed copy goes to connected couriers in parallel, and receivers
|
||||
/// dedup by message ID if both arrive.
|
||||
@Test @MainActor
|
||||
func queuedReachableSendAlsoDepositsWithCourier() {
|
||||
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 transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
|
||||
]
|
||||
transport.reachablePeers = [bobID]
|
||||
transport.promptDelivery = false
|
||||
|
||||
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: "m4")
|
||||
|
||||
#expect(transport.directSends == ["m4"])
|
||||
#expect(transport.courierSends.count == 1)
|
||||
#expect(transport.courierSends.first?.messageID == "m4")
|
||||
#expect(transport.courierSends.first?.couriers == [carolID])
|
||||
#expect(carried == ["m4"])
|
||||
}
|
||||
|
||||
/// When the reachable transport can deliver promptly (relays up), the
|
||||
/// send is trusted and no courier quota is spent.
|
||||
@Test @MainActor
|
||||
func promptlyDeliverableReachablePeerSkipsCourier() {
|
||||
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 transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
|
||||
]
|
||||
transport.reachablePeers = [bobID]
|
||||
|
||||
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: "m5")
|
||||
|
||||
#expect(transport.directSends == ["m5"])
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
#expect(carried.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -49,6 +49,21 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
XCTAssertFalse(service.isMutualFavorite(peerKey))
|
||||
}
|
||||
|
||||
func test_updatePeerFavoritedUs_keepsStoredNicknameOverUnknownPlaceholder() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((128..<160).map(UInt8.init))
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Erin")
|
||||
|
||||
// A notification arriving before the peer is known passes "Unknown".
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Unknown")
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin")
|
||||
|
||||
// A real nickname still updates the stored one.
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Erin2")
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin2")
|
||||
}
|
||||
|
||||
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((96..<128).map(UInt8.init))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@@ -42,7 +43,9 @@ struct NostrTransportTests {
|
||||
)
|
||||
)
|
||||
|
||||
#expect(!transport.isPeerReachable(fullPeerID))
|
||||
// Offline favorites are addressed by the full 64-hex noise key, so
|
||||
// both forms must resolve to the same reachability answer.
|
||||
#expect(transport.isPeerReachable(fullPeerID))
|
||||
#expect(transport.isPeerReachable(shortPeerID))
|
||||
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
||||
}
|
||||
@@ -83,6 +86,51 @@ struct NostrTransportTests {
|
||||
#expect(didRefresh)
|
||||
}
|
||||
|
||||
@Test("Prompt delivery requires both a known npub and a relay connection")
|
||||
@MainActor
|
||||
func canDeliverPromptlyTracksRelayConnectivity() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
let connectivity = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
loadFavorites: { [noiseKey: relationship] },
|
||||
relayConnectivity: { connectivity.eraseToAnyPublisher() }
|
||||
)
|
||||
)
|
||||
|
||||
// Reachable (npub known) but relays down: the peer must not be
|
||||
// treated as promptly deliverable, or the router would skip the
|
||||
// courier and let the message rot in the Nostr send queue.
|
||||
#expect(transport.isPeerReachable(peerID))
|
||||
#expect(!transport.canDeliverPromptly(to: peerID))
|
||||
|
||||
connectivity.send(true)
|
||||
let deliverable = await TestHelpers.waitUntil(
|
||||
{ transport.canDeliverPromptly(to: peerID) },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(deliverable)
|
||||
|
||||
connectivity.send(false)
|
||||
let undeliverable = await TestHelpers.waitUntil(
|
||||
{ !transport.canDeliverPromptly(to: peerID) },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(undeliverable)
|
||||
}
|
||||
|
||||
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||
@MainActor
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
@@ -373,7 +421,8 @@ struct NostrTransportTests {
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
|
||||
) -> NostrTransport.Dependencies {
|
||||
NostrTransport.Dependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
@@ -383,7 +432,8 @@ struct NostrTransportTests {
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||
sendEvent: sendEvent,
|
||||
scheduleAfter: scheduleAfter
|
||||
scheduleAfter: scheduleAfter,
|
||||
relayConnectivity: relayConnectivity
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,117 @@ struct UnifiedPeerServiceTests {
|
||||
#expect(!service.isBlocked(peerID))
|
||||
}
|
||||
|
||||
// MARK: - Offline-favorite dedup (updatePeers phase 2)
|
||||
|
||||
/// A mutual favorite that is also on the mesh must collapse to a single
|
||||
/// row keyed by the short mesh ID — even when the announced nickname no
|
||||
/// longer matches the one stored with the favorite.
|
||||
@Test @MainActor
|
||||
func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
let meshID = PeerID(publicKey: noiseKey)
|
||||
let snapshots = [TransportPeerSnapshot(
|
||||
peerID: meshID,
|
||||
nickname: "alice-renamed",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)]
|
||||
transport.updatePeerSnapshots(snapshots)
|
||||
service.didUpdatePeerSnapshots(snapshots)
|
||||
|
||||
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.peerID == meshID)
|
||||
#expect(rows.first?.isMutualFavorite == true)
|
||||
#expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1)
|
||||
}
|
||||
|
||||
/// Same collapse must hold for a reachable-but-not-connected favorite
|
||||
/// (relayed peers linger as "reachable" after their link drops).
|
||||
@Test @MainActor
|
||||
func updatePeers_reachableMutualFavoriteYieldsSingleRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xCD, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
let otherKey = Data(repeating: 0x11, count: 32)
|
||||
let snapshots = [
|
||||
// A live link is required for anyone to count as reachable.
|
||||
TransportPeerSnapshot(
|
||||
peerID: PeerID(publicKey: otherKey),
|
||||
nickname: "carol",
|
||||
isConnected: true,
|
||||
noisePublicKey: otherKey,
|
||||
lastSeen: Date()
|
||||
),
|
||||
TransportPeerSnapshot(
|
||||
peerID: PeerID(publicKey: noiseKey),
|
||||
nickname: "bob",
|
||||
isConnected: false,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
]
|
||||
transport.updatePeerSnapshots(snapshots)
|
||||
service.didUpdatePeerSnapshots(snapshots)
|
||||
|
||||
let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(bobRows.count == 1)
|
||||
#expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey))
|
||||
#expect(bobRows.first?.isReachable == true)
|
||||
}
|
||||
|
||||
/// A mutual favorite with no mesh presence still gets its offline row,
|
||||
/// keyed by the full noise-key PeerID.
|
||||
@Test @MainActor
|
||||
func updatePeers_offlineMutualFavoriteGetsOfflineRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xEF, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
transport.updatePeerSnapshots([])
|
||||
service.didUpdatePeerSnapshots([])
|
||||
|
||||
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.peerID == PeerID(hexData: noiseKey))
|
||||
#expect(rows.first?.isMutualFavorite == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func setBlocked_unknownIdentityReturnsNil() async {
|
||||
let transport = MockTransport()
|
||||
|
||||
@@ -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,6 +11,7 @@ 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)
|
||||
@@ -22,6 +23,8 @@ public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
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, _):
|
||||
|
||||
@@ -14,6 +14,7 @@ public enum MessageType: UInt8 {
|
||||
case announce = 0x01 // "I'm here" with nickname
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user