mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:45:19 +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
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
Generated
+4
-4
@@ -561,7 +561,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -620,7 +620,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -749,7 +749,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ enum NoisePattern {
|
|||||||
case XX // Most versatile, mutual authentication
|
case XX // Most versatile, mutual authentication
|
||||||
case IK // Initiator knows responder's static key
|
case IK // Initiator knows responder's static key
|
||||||
case NK // Anonymous initiator
|
case NK // Anonymous initiator
|
||||||
|
case X // One-way: single message to a known static key (no response)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NoiseRole {
|
enum NoiseRole {
|
||||||
@@ -601,7 +602,7 @@ final class NoiseHandshakeState {
|
|||||||
switch pattern {
|
switch pattern {
|
||||||
case .XX:
|
case .XX:
|
||||||
break // No pre-message keys
|
break // No pre-message keys
|
||||||
case .IK, .NK:
|
case .IK, .NK, .X:
|
||||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||||
} else if role == .responder, let localStatic = localStaticPublic {
|
} else if role == .responder, let localStatic = localStaticPublic {
|
||||||
@@ -904,6 +905,7 @@ extension NoisePattern {
|
|||||||
case .XX: return "XX"
|
case .XX: return "XX"
|
||||||
case .IK: return "IK"
|
case .IK: return "IK"
|
||||||
case .NK: return "NK"
|
case .NK: return "NK"
|
||||||
|
case .X: return "X"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,6 +927,10 @@ extension NoisePattern {
|
|||||||
[.e, .es], // -> e, es
|
[.e, .es], // -> e, es
|
||||||
[.e, .ee] // <- e, ee
|
[.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 relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@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 let dependencies: NostrRelayManagerDependencies
|
||||||
private var allowDefaultRelays: Bool = false
|
private var allowDefaultRelays: Bool = false
|
||||||
@@ -1087,6 +1091,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func updateConnectionStatus() {
|
private func updateConnectionStatus() {
|
||||||
isConnected = relays.contains { $0.isConnected }
|
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
|
/// A relay that drops before sending EOSE must not stall initial-load
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let scheduleAfterglow: (TimeInterval) -> Void
|
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
|
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||||
/// trust, registry/topology updates, identity persistence, UI notification,
|
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||||
/// gossip tracking, and the reciprocal announce response.
|
/// gossip tracking, and the reciprocal announce response.
|
||||||
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
@discardableResult
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
|
||||||
let env = environment
|
let env = environment
|
||||||
let now = env.now()
|
let now = env.now()
|
||||||
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||||
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
|
|||||||
announcement = acceptance.announcement
|
announcement = acceptance.announcement
|
||||||
case .reject(.malformed):
|
case .reject(.malformed):
|
||||||
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||||
return
|
return nil
|
||||||
case .reject(.senderMismatch(let derivedFromKey)):
|
case .reject(.senderMismatch(let derivedFromKey)):
|
||||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||||
return
|
return nil
|
||||||
case .reject(.selfAnnounce):
|
case .reject(.selfAnnounce):
|
||||||
return
|
return nil
|
||||||
case .reject(.stale(let ageSeconds)):
|
case .reject(.stale(let ageSeconds)):
|
||||||
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
@@ -210,5 +220,12 @@ final class BLEAnnounceHandler {
|
|||||||
let delay = Double.random(in: 0.3...0.6)
|
let delay = Double.random(in: 0.3...0.6)
|
||||||
env.scheduleAfterglow(delay)
|
env.scheduleAfterglow(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return BLEAnnounceHandlingResult(
|
||||||
|
peerID: peerID,
|
||||||
|
announcement: announcement,
|
||||||
|
isDirectAnnounce: isDirectAnnounce,
|
||||||
|
isVerified: verifiedAnnounce
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
|
|||||||
packetType: UInt8,
|
packetType: UInt8,
|
||||||
messageID: String
|
messageID: String
|
||||||
) -> BLEFanoutSelection {
|
) -> 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(
|
let allowed = collapseDuplicateLinksPerPeer(
|
||||||
allowedLinks(
|
rawAllowed,
|
||||||
peripheralIDs: peripheralIDs,
|
|
||||||
centralIDs: centralIDs,
|
|
||||||
ingressLink: ingressLink,
|
|
||||||
excludedLinks: excludedLinks
|
|
||||||
),
|
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
centralPeerBindings: centralPeerBindings
|
centralPeerBindings: centralPeerBindings
|
||||||
)
|
)
|
||||||
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
|
|||||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
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
|
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||||
// peripheral, and they-as-central subscribed to ours). Sending the same
|
// peripheral, and they-as-central subscribed to ours). Sending the same
|
||||||
// packet down both doubles airtime for nothing — the receiver's assembler
|
// packet down both doubles airtime for nothing — the receiver's assembler
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// 4. Efficient Message Deduplication
|
// 4. Efficient Message Deduplication
|
||||||
private let messageDeduplicator = MessageDeduplicator()
|
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 var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||||
private let meshTopology = MeshTopologyTracker()
|
private let meshTopology = MeshTopologyTracker()
|
||||||
|
|
||||||
@@ -888,6 +902,10 @@ final class BLEService: NSObject {
|
|||||||
} else {
|
} else {
|
||||||
packetToSend = packet
|
packetToSend = packet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
_test_onOutboundPacket?(packetToSend)
|
||||||
|
#endif
|
||||||
|
|
||||||
// Encode once using a small per-type padding policy, then delegate by type
|
// Encode once using a small per-type padding policy, then delegate by type
|
||||||
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
|
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
|
||||||
@@ -1047,6 +1065,9 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||||
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
||||||
|
#if DEBUG
|
||||||
|
_test_onOutboundPacket?(packet)
|
||||||
|
#endif
|
||||||
guard let data = packet.toBinaryData(padding: false) else { return }
|
guard let data = packet.toBinaryData(padding: false) else { return }
|
||||||
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
||||||
}
|
}
|
||||||
@@ -2468,7 +2489,147 @@ extension BLEService {
|
|||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Courier Store-and-Forward
|
||||||
|
|
||||||
|
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
|
||||||
|
/// the envelope to the given couriers for physical delivery. Returns false
|
||||||
|
/// when no courier is connected, the payload cannot be built, or sealing
|
||||||
|
/// fails; link writes are queued asynchronously after the envelope is ready.
|
||||||
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||||
|
let connected = couriers.filter { isPeerConnected($0) }
|
||||||
|
guard !connected.isEmpty,
|
||||||
|
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Data
|
||||||
|
do {
|
||||||
|
let now = Date()
|
||||||
|
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||||
|
let envelope = CourierEnvelope(
|
||||||
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
|
noiseStaticKey: recipientNoiseKey,
|
||||||
|
epochDay: CourierEnvelope.epochDay(for: now)
|
||||||
|
),
|
||||||
|
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||||
|
ciphertext: sealed
|
||||||
|
)
|
||||||
|
guard let encoded = envelope.encode() else { return false }
|
||||||
|
payload = encoded
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for courier in connected {
|
||||||
|
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
|
senderID: myPeerIDData,
|
||||||
|
recipientID: Data(hexString: peerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles both courier roles for an incoming envelope addressed to us:
|
||||||
|
/// recipient (the rotating tag matches our static key → open and deliver)
|
||||||
|
/// or courier (a trusted peer is depositing mail for someone else → store).
|
||||||
|
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
// Directed packets only; envelopes addressed elsewhere ride the
|
||||||
|
// generic relay path untouched.
|
||||||
|
guard packet.recipientID == myPeerIDData else { return }
|
||||||
|
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
|
||||||
|
|
||||||
|
let myKey = noiseService.getStaticPublicKeyData()
|
||||||
|
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
||||||
|
openCourierEnvelope(envelope)
|
||||||
|
} else {
|
||||||
|
acceptCourierDeposit(envelope, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||||
|
do {
|
||||||
|
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||||
|
guard let typeRaw = typedPayload.first,
|
||||||
|
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||||
|
payloadType == .privateMessage else {
|
||||||
|
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Couriered mail arrives while the sender is absent, so the UI's
|
||||||
|
// block check can't resolve their fingerprint from a live session.
|
||||||
|
// Gate here, where the full static key is in hand.
|
||||||
|
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
|
||||||
|
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 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)
|
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
||||||
|
|
||||||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||||||
@@ -2601,8 +2762,11 @@ extension BLEService {
|
|||||||
// MARK: Private Message Handling
|
// MARK: Private Message Handling
|
||||||
|
|
||||||
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
|
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)
|
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
|
// Check if we have an established Noise session
|
||||||
if noiseService.hasEstablishedSession(with: recipientID) {
|
if noiseService.hasEstablishedSession(with: recipientID) {
|
||||||
// Encrypt and send
|
// Encrypt and send
|
||||||
@@ -2953,7 +3117,10 @@ extension BLEService {
|
|||||||
|
|
||||||
case .fileTransfer:
|
case .fileTransfer:
|
||||||
handleFileTransfer(packet, from: senderID)
|
handleFileTransfer(packet, from: senderID)
|
||||||
|
|
||||||
|
case .courierEnvelope:
|
||||||
|
handleCourierEnvelope(packet, from: peerID)
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
handleLeave(packet, from: senderID)
|
||||||
|
|
||||||
@@ -3015,7 +3182,18 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
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
|
/// Builds the announce handler environment. All queue hops stay here so
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
func addPublicSystemMessage(_ content: String)
|
func addPublicSystemMessage(_ content: String)
|
||||||
|
|
||||||
// MARK: - Favorites
|
// 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 toggleFavorite(peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes chat commands in a focused, efficient way
|
/// Processes chat commands in a focused, efficient way
|
||||||
@@ -335,34 +336,31 @@ final class CommandProcessor {
|
|||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||||
}
|
}
|
||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if add {
|
// Resolve current state by the peer's real noise key. The resolved
|
||||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
// noise-key ID (offline favorite row) — never the noise key itself.
|
||||||
peerNoisePublicKey: noisePublicKey,
|
let isCurrentlyFavorite: Bool
|
||||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
if let noiseKey = peerID.noiseKey {
|
||||||
peerNickname: nickname
|
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
||||||
)
|
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
|
||||||
|
|
||||||
return .success(message: "added \(nickname) to favorites")
|
|
||||||
} else {
|
} else {
|
||||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
|
||||||
|
|
||||||
return .success(message: "removed \(nickname) from favorites")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
peerNostrPublicKey: String? = nil
|
||||||
) {
|
) {
|
||||||
let existing = favorites[peerNoisePublicKey]
|
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)
|
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,37 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
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.)
|
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
|
private let courierDirectory: CourierDirectory
|
||||||
|
|
||||||
/// Invoked whenever a retained private message is dropped without a
|
/// Invoked whenever a retained private message is dropped without a
|
||||||
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
||||||
@@ -14,6 +40,12 @@ final class MessageRouter {
|
|||||||
/// stale "sending/sent" state forever.
|
/// stale "sending/sent" state forever.
|
||||||
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
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
|
// Outbox entry with timestamp for TTL-based eviction
|
||||||
private struct QueuedMessage {
|
private struct QueuedMessage {
|
||||||
let content: String
|
let content: String
|
||||||
@@ -31,10 +63,17 @@ final class MessageRouter {
|
|||||||
// Bound resends of messages sent on a weak reachability signal that never
|
// 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).
|
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||||
private static let maxSendAttempts = 8
|
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.transports = transports
|
||||||
self.now = now
|
self.now = now
|
||||||
|
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
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)
|
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)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
enqueue(message, for: peerID)
|
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 {
|
} else {
|
||||||
var unsent = message
|
var unsent = message
|
||||||
unsent.sendAttempts = 0
|
unsent.sendAttempts = 0
|
||||||
enqueue(unsent, for: peerID)
|
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)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,49 @@ final class NoiseEncryptionService {
|
|||||||
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
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)
|
/// Clear persistent identity (for panic mode)
|
||||||
func clearPersistentIdentity() {
|
func clearPersistentIdentity() {
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> 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 {
|
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||||
Dependencies(
|
Dependencies(
|
||||||
notificationCenter: .default,
|
notificationCenter: .default,
|
||||||
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
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)
|
// Reachability Cache (thread-safe)
|
||||||
private var reachablePeers: Set<PeerID> = []
|
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)
|
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.reachablePeers = Set(reachable)
|
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 {
|
deinit {
|
||||||
@@ -125,16 +142,22 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
|
|
||||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||||
queue.sync {
|
// Callers address peers by either the short 16-hex ID or the full
|
||||||
// Check if exact match
|
// 64-hex noise key (offline favorites), so compare in short form.
|
||||||
|
let short = peerID.toShort()
|
||||||
|
return queue.sync {
|
||||||
if reachablePeers.contains(peerID) { return true }
|
if reachablePeers.contains(peerID) { return true }
|
||||||
// Check for short ID match
|
return reachablePeers.contains(where: { $0.toShort() == short })
|
||||||
if peerID.isShort {
|
|
||||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
case .read, .delivered:
|
case .read, .delivered:
|
||||||
externalReceipts.insert(message.id)
|
externalReceipts.insert(message.id)
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
case .failed, .partiallyDelivered, .sending, .sent:
|
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ protocol Transport: AnyObject {
|
|||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
func isPeerReachable(_ 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 peerNickname(peerID: PeerID) -> String?
|
||||||
func getPeerNicknames() -> [PeerID: String]
|
func getPeerNicknames() -> [PeerID: String]
|
||||||
|
|
||||||
@@ -95,6 +100,12 @@ protocol Transport: AnyObject {
|
|||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||||
func cancelTransfer(_ 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)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(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 {
|
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 identity hooks default to inert for transports that do not carry
|
||||||
// Noise sessions (e.g. NostrTransport).
|
// Noise sessions (e.g. NostrTransport).
|
||||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||||
@@ -120,6 +135,7 @@ extension Transport {
|
|||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(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 sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|||||||
@@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<PeerID> = []
|
var connected: Set<PeerID> = []
|
||||||
var addedPeerIDs: Set<PeerID> = []
|
var addedPeerIDs: Set<PeerID> = []
|
||||||
|
var meshNoiseKeys: Set<Data> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers {
|
||||||
let peerID = peerInfo.peerID
|
let peerID = peerInfo.peerID
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
peerInfo: peerInfo,
|
peerInfo: peerInfo,
|
||||||
favorites: favorites,
|
favorites: favorites,
|
||||||
meshAttached: hasAnyConnected
|
meshAttached: hasAnyConnected
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichedPeers.append(peer)
|
enrichedPeers.append(peer)
|
||||||
if peer.isConnected { connected.insert(peerID) }
|
if peer.isConnected { connected.insert(peerID) }
|
||||||
addedPeerIDs.insert(peerID)
|
addedPeerIDs.insert(peerID)
|
||||||
|
|
||||||
// Update fingerprint cache
|
// Update fingerprint cache
|
||||||
if let publicKey = peerInfo.noisePublicKey {
|
if let publicKey = peerInfo.noisePublicKey {
|
||||||
|
meshNoiseKeys.insert(publicKey)
|
||||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
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 {
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||||
|
if meshNoiseKeys.contains(favoriteKey) { continue }
|
||||||
|
|
||||||
let peerID = PeerID(hexData: favoriteKey)
|
let peerID = PeerID(hexData: favoriteKey)
|
||||||
|
|
||||||
// Skip if already added (connected peer)
|
|
||||||
if addedPeerIDs.contains(peerID) { continue }
|
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)
|
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||||
enrichedPeers.append(peer)
|
enrichedPeers.append(peer)
|
||||||
addedPeerIDs.insert(peerID)
|
addedPeerIDs.insert(peerID)
|
||||||
|
|
||||||
// Update fingerprint cache
|
// Update fingerprint cache
|
||||||
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
|
|||||||
case .fragment: return 5
|
case .fragment: return 5
|
||||||
case .requestSync: return 6
|
case .requestSync: return 6
|
||||||
case .fileTransfer: return 7
|
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 .failed: return 1
|
||||||
case .sending: return 2
|
case .sending: return 2
|
||||||
case .sent: return 3
|
case .sent: return 3
|
||||||
case .partiallyDelivered: return 4
|
case .carried: return 4
|
||||||
case .delivered: return 5
|
case .partiallyDelivered: return 5
|
||||||
case .read: return 6
|
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 sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
func sendGeohashReadReceipt(_ 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.
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
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 {
|
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
|
@MainActor
|
||||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||||
guard let context else { return }
|
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 sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
func sendGeohashReadReceipt(_ 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
|
// MARK: System messages
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
@@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject {
|
|||||||
// MARK: Favorites & notifications
|
// MARK: Favorites & notifications
|
||||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
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).
|
/// Persists that the peer favorited/unfavorited us (favorites store write).
|
||||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
||||||
/// Posts the incoming-private-message local notification.
|
/// Posts the incoming-private-message local notification.
|
||||||
@@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
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?) {
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
||||||
peerNoisePublicKey: noiseKey,
|
peerNoisePublicKey: noiseKey,
|
||||||
@@ -245,10 +251,15 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
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 isConnected = context.isPeerConnected(peerID)
|
||||||
let isReachable = context.isPeerReachable(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 isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||||
|
|
||||||
@@ -405,9 +416,32 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
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 }
|
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||||
|
|
||||||
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
@@ -486,93 +520,6 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
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) {
|
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||||
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
||||||
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
||||||
@@ -583,7 +530,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
|
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
|
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 isFavorite = content.hasPrefix("[FAVORITED]")
|
||||||
let parts = content.split(separator: ":")
|
let parts = content.split(separator: ":")
|
||||||
|
|
||||||
|
|||||||
@@ -1189,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear persistent favorites from keychain
|
// Clear persistent favorites from keychain
|
||||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
|
|
||||||
|
// Drop courier mail carried for third parties (memory and disk)
|
||||||
|
CourierStore.shared.wipe()
|
||||||
|
|
||||||
// Identity manager has cleared persisted identity data above
|
// Identity manager has cleared persisted identity data above
|
||||||
|
|
||||||
// Clear autocomplete state
|
// 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.contextProvider = viewModel
|
||||||
viewModel.commandProcessor.meshService = viewModel.meshService
|
viewModel.commandProcessor.meshService = viewModel.meshService
|
||||||
viewModel.participantTracker.configure(context: viewModel)
|
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
|
@MainActor
|
||||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||||
|
|||||||
@@ -121,25 +121,6 @@ extension ChatViewModel {
|
|||||||
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
|
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
|
@MainActor
|
||||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||||
privateConversationCoordinator.handlePrivateMessage(message)
|
privateConversationCoordinator.handlePrivateMessage(message)
|
||||||
@@ -190,8 +171,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||||
privateConversationCoordinator.handleFavoriteNotificationFromMesh(
|
privateConversationCoordinator.handleFavoriteNotification(
|
||||||
content,
|
content,
|
||||||
from: peerID,
|
from: peerID,
|
||||||
senderNickname: senderNickname
|
senderNickname: senderNickname
|
||||||
|
|||||||
@@ -442,8 +442,7 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
||||||
/// store. Lives here because the inbound DM path needs it per message;
|
/// store. Lives here because the inbound DM path needs it per message.
|
||||||
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
guard let context else { return nil }
|
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")
|
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||||
case .sent:
|
case .sent:
|
||||||
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
|
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, _):
|
case .delivered(let nickname, _):
|
||||||
return String(
|
return String(
|
||||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||||
@@ -80,6 +82,11 @@ struct DeliveryStatusView: View {
|
|||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
|
case .carried:
|
||||||
|
Image(systemName: "figure.walk")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||||
|
|
||||||
case .delivered:
|
case .delivered:
|
||||||
HStack(spacing: -2) {
|
HStack(spacing: -2) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
@@ -120,6 +127,7 @@ struct DeliveryStatusView: View {
|
|||||||
let statuses: [DeliveryStatus] = [
|
let statuses: [DeliveryStatus] = [
|
||||||
.sending,
|
.sending,
|
||||||
.sent,
|
.sent,
|
||||||
|
.carried,
|
||||||
.delivered(to: "John Doe", at: Date()),
|
.delivered(to: "John Doe", at: Date()),
|
||||||
.read(by: "Jane Doe", at: Date()),
|
.read(by: "Jane Doe", at: Date()),
|
||||||
.failed(reason: "Offline"),
|
.failed(reason: "Offline"),
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
|
|||||||
let headerPeerIconSize: CGFloat
|
let headerPeerIconSize: CGFloat
|
||||||
let headerPeerCountFontSize: CGFloat
|
let headerPeerCountFontSize: CGFloat
|
||||||
|
|
||||||
|
/// Courier envelopes this device is carrying for offline third parties.
|
||||||
|
@State private var carriedMailCount = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(verbatim: "bitchat/")
|
Text(verbatim: "bitchat/")
|
||||||
@@ -88,6 +91,23 @@ struct ContentHeaderView: View {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
HStack(spacing: 2) {
|
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 {
|
if appChromeModel.hasUnreadPrivateMessages {
|
||||||
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
|
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
@@ -214,6 +234,9 @@ struct ContentHeaderView: View {
|
|||||||
// Type.
|
// Type.
|
||||||
.frame(height: headerHeight)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
.onReceive(CourierStore.shared.$carriedCount) { count in
|
||||||
|
carriedMailCount = count
|
||||||
|
}
|
||||||
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
||||||
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
||||||
.environmentObject(locationChannelsModel)
|
.environmentObject(locationChannelsModel)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ struct MediaMessageView: View {
|
|||||||
isSending = true
|
isSending = true
|
||||||
progress = Double(reached) / Double(total)
|
progress = Double(reached) / Double(total)
|
||||||
}
|
}
|
||||||
case .sent, .read, .delivered, .failed:
|
case .sent, .carried, .read, .delivered, .failed:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -608,34 +608,6 @@ struct GeoPresenceTrackerTests {
|
|||||||
#expect(stamped > stale)
|
#expect(stamped > stale)
|
||||||
#expect(context.appendedGeohashMessages.count == 1)
|
#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
|
@Test @MainActor
|
||||||
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
|
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
favoriteRelationshipsByNoiseKey[noiseKey]
|
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?) {
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||||
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
|
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
|
||||||
}
|
}
|
||||||
@@ -549,14 +553,14 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
|
func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||||
let peerID = PeerID(hexData: noiseKey)
|
let peerID = PeerID(hexData: noiseKey)
|
||||||
|
|
||||||
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
|
// 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.count == 1)
|
||||||
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
||||||
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
||||||
@@ -568,16 +572,79 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
noiseKey: noiseKey,
|
noiseKey: noiseKey,
|
||||||
theyFavoritedUs: true
|
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.peerFavoritedUsUpdates.count == 2)
|
||||||
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
||||||
|
|
||||||
// [UNFAVORITED] transition announces again.
|
// [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.peerFavoritedUsUpdates.last?.favorited == false)
|
||||||
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
|
#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
|
@Test @MainActor
|
||||||
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
|
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
@@ -602,6 +669,32 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(context.systemMessages.isEmpty)
|
#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
|
@Test @MainActor
|
||||||
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
|
|||||||
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func statusRank_orderingIsCorrect() async {
|
func statusRank_orderingIsCorrect() async {
|
||||||
// This tests the implicit ordering used in refreshVisibleMessages
|
// This tests the implicit ordering used in refreshVisibleMessages
|
||||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
// failed < sending < sent < carried < partiallyDelivered < delivered < read
|
||||||
|
|
||||||
let statuses: [DeliveryStatus] = [
|
let statuses: [DeliveryStatus] = [
|
||||||
.failed(reason: "test"),
|
.failed(reason: "test"),
|
||||||
.sending,
|
.sending,
|
||||||
.sent,
|
.sent,
|
||||||
|
.carried,
|
||||||
.partiallyDelivered(reached: 1, total: 3),
|
.partiallyDelivered(reached: 1, total: 3),
|
||||||
.delivered(to: "B", at: Date()),
|
.delivered(to: "B", at: Date()),
|
||||||
.read(by: "C", at: Date())
|
.read(by: "C", at: Date())
|
||||||
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
case .failed: #expect(index == 0)
|
case .failed: #expect(index == 0)
|
||||||
case .sending: #expect(index == 1)
|
case .sending: #expect(index == 1)
|
||||||
case .sent: #expect(index == 2)
|
case .sent: #expect(index == 2)
|
||||||
case .partiallyDelivered: #expect(index == 3)
|
case .carried: #expect(index == 3)
|
||||||
case .delivered: #expect(index == 4)
|
case .partiallyDelivered: #expect(index == 4)
|
||||||
case .read: #expect(index == 5)
|
case .delivered: #expect(index == 5)
|
||||||
|
case .read: #expect(index == 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -655,8 +655,10 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
|
||||||
|
/// out of the conversation transcript.
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
|
func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
||||||
@@ -664,19 +666,33 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
FavoritesPersistenceService.shared.addFavorite(
|
FavoritesPersistenceService.shared.addFavorite(
|
||||||
peerNoisePublicKey: noiseKey,
|
peerNoisePublicKey: noiseKey,
|
||||||
peerNostrPublicKey: identity.npub,
|
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(
|
// The inbound pipeline resolves a known sender to their noise-key ID.
|
||||||
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
|
let convKey = PeerID(hexData: noiseKey)
|
||||||
from: identity.publicKeyHex
|
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)
|
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?.peerNostrPublicKey == identity.npub)
|
||||||
#expect(relationship?.isFavorite == true)
|
#expect(viewModel.privateChats[convKey, default: []].isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -303,6 +303,50 @@ struct CommandProcessorTests {
|
|||||||
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
#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
|
@MainActor
|
||||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||||
let identityManager = MockIdentityManager(MockKeychain())
|
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)
|
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
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.count == 1)
|
||||||
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
|
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
|
||||||
#expect(recorder.barrierCount == 1)
|
#expect(recorder.barrierCount == 1)
|
||||||
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
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.verifySignatureCalls.isEmpty)
|
||||||
#expect(recorder.barrierCount == 1)
|
#expect(recorder.barrierCount == 1)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
@@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
recorder.signatureValid = false
|
recorder.signatureValid = false
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
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.verifySignatureCalls.count == 1)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
#expect(recorder.uiEventDeliveries.count == 1)
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
@@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result == nil)
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
|
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)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result == nil)
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests {
|
|||||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
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.count == 1)
|
||||||
#expect(recorder.upsertCalls.first?.isConnected == false)
|
#expect(recorder.upsertCalls.first?.isConnected == false)
|
||||||
#expect(recorder.uiEventDeliveries.count == 1)
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
@@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
|
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
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.upsertCalls.isEmpty)
|
||||||
#expect(recorder.uiEventDeliveries.count == 1)
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
|
|||||||
@@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests {
|
|||||||
#expect(selection.centralIDs == Set(["c2"]))
|
#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
|
@Test
|
||||||
func directedSendExcludesAllLinksToIngressPeer() {
|
func directedSendExcludesAllLinksToIngressPeer() {
|
||||||
let selection = BLEFanoutSelector.selectLinks(
|
let selection = BLEFanoutSelector.selectLinks(
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
|||||||
XCTAssertFalse(service.isMutualFavorite(peerKey))
|
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() {
|
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
|
||||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||||
let peerKey = Data((96..<128).map(UInt8.init))
|
let peerKey = Data((96..<128).map(UInt8.init))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
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(shortPeerID))
|
||||||
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
||||||
}
|
}
|
||||||
@@ -83,6 +86,51 @@ struct NostrTransportTests {
|
|||||||
#expect(didRefresh)
|
#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")
|
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||||
@@ -373,7 +421,8 @@ struct NostrTransportTests {
|
|||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||||
sendEvent: @escaping @MainActor (NostrEvent) -> 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 {
|
||||||
NostrTransport.Dependencies(
|
NostrTransport.Dependencies(
|
||||||
notificationCenter: notificationCenter,
|
notificationCenter: notificationCenter,
|
||||||
@@ -383,7 +432,8 @@ struct NostrTransportTests {
|
|||||||
currentIdentity: currentIdentity,
|
currentIdentity: currentIdentity,
|
||||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||||
sendEvent: sendEvent,
|
sendEvent: sendEvent,
|
||||||
scheduleAfter: scheduleAfter
|
scheduleAfter: scheduleAfter,
|
||||||
|
relayConnectivity: relayConnectivity
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,117 @@ struct UnifiedPeerServiceTests {
|
|||||||
#expect(!service.isBlocked(peerID))
|
#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
|
@Test @MainActor
|
||||||
func setBlocked_unknownIdentityReturnsNil() async {
|
func setBlocked_unknownIdentityReturnsNil() async {
|
||||||
let transport = MockTransport()
|
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,17 +11,20 @@ import struct Foundation.Date
|
|||||||
public enum DeliveryStatus: Codable, Equatable, Hashable {
|
public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
case sending
|
case sending
|
||||||
case sent // Left our device
|
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 delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
case read(by: String, at: Date) // Seen by recipient
|
case read(by: String, at: Date) // Seen by recipient
|
||||||
case failed(reason: String)
|
case failed(reason: String)
|
||||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||||
|
|
||||||
public var displayText: String {
|
public var displayText: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .sending:
|
case .sending:
|
||||||
return "Sending..."
|
return "Sending..."
|
||||||
case .sent:
|
case .sent:
|
||||||
return "Sent"
|
return "Sent"
|
||||||
|
case .carried:
|
||||||
|
return "Carried by a friend"
|
||||||
case .delivered(let nickname, _):
|
case .delivered(let nickname, _):
|
||||||
return "Delivered to \(nickname)"
|
return "Delivered to \(nickname)"
|
||||||
case .read(let nickname, _):
|
case .read(let nickname, _):
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
public enum MessageType: UInt8 {
|
public enum MessageType: UInt8 {
|
||||||
// Public messages (unencrypted)
|
// Public messages (unencrypted)
|
||||||
case announce = 0x01 // "I'm here" with nickname
|
case announce = 0x01 // "I'm here" with nickname
|
||||||
case message = 0x02 // Public chat message
|
case message = 0x02 // Public chat message
|
||||||
case leave = 0x03 // "I'm leaving"
|
case 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)
|
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||||
|
|
||||||
// Noise encryption
|
// Noise encryption
|
||||||
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
|
|||||||
case .announce: return "announce"
|
case .announce: return "announce"
|
||||||
case .message: return "message"
|
case .message: return "message"
|
||||||
case .leave: return "leave"
|
case .leave: return "leave"
|
||||||
|
case .courierEnvelope: return "courierEnvelope"
|
||||||
case .requestSync: return "requestSync"
|
case .requestSync: return "requestSync"
|
||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
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